Compare commits

...
155 Commits
Author SHA1 Message Date
ae605f2919 chore(deps): bump modernc.org/sqlite from 1.50.1 to 1.59.0 (#1813)
Motivation:
modernc.org/sqlite versions 1.39.0 through at least 1.50.1 (the version
previously pinned in go.mod) fail to compile on NetBSD amd64, breaking
any package that depends on gatus's sqlite storage backend on that
platform. The breakage is tracked upstream at
https://gitlab.com/cznic/sqlite/-/work_items/246, and is reported as
fixed starting with modernc.org/sqlite v1.53.0.

Approach:
Ran `go get modernc.org/sqlite@v1.59.0` (the latest available release,
newer than the minimum v1.53.0 fix version) followed by `go mod tidy`,
matching this repo's established convention of bumping straight to the
latest release rather than a minimum-fix version. This only touches
go.mod/go.sum; storage/store/sql/sql.go only uses the driver via the
standard `_ "modernc.org/sqlite"` blank import and generic database/sql
calls, so it is unaffected by internal changes in the new version. The
bump also pulls in a few indirect dependency updates (mattn/go-isatty,
modernc.org/libc, modernc.org/memory, and sqlite's own codegen
dependencies) via Go's module version selection.

Validation:
Ran `go build`, `go vet ./...`, and `go test ./... -race`, mirroring
this repo's test.yml CI workflow (minus the `sudo` it uses for a
ping-based test, which is unrelated to this change) — all packages
build and pass with the new dependency version, confirming no
regression on this platform. Also ran `go mod verify` and a second
`go mod tidy`, both producing no additional diff. The NetBSD build
fix itself could not be reproduced directly since this development
environment is macOS/arm64; the fix is bumping past the version range
the upstream sqlite issue identifies as broken.

Report: https://github.com/TwiN/gatus/issues/1709

Assisted-by: claude-sonnet-5 (via Claude Code)

Signed-off-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com>
Co-authored-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com>
2026-09-23 19:35:29 -04:00
374de23cdd test(config): make HasLoadedConfigurationBeenModified deterministic (#1786)
* test(config): make HasLoadedConfigurationBeenModified deterministic

The two subtests each slept a full second so a rewritten file's mod time
(second precision) would advance past the load time. Advance the mod time
explicitly with os.Chtimes instead, and give the directory subtest its own
temp dir so the file subtest's future-dated config.yaml no longer leaks
into it. Removes ~2s of wall-clock sleep and the coarse-clock dependence;
the test is now deterministic.

* test(config): trim comments to the forward-facing rationale

---------

Co-authored-by: TwiN <twin@linux.com>
2026-09-21 21:08:52 -04:00
7f3873d0b4 feat(alerting): allow configuring the topic for Zulip alerts (#1764)
Zulip alerts previously always used a hardcoded "Gatus" topic, sending
every alert and resolution into a single thread. Add a "topic" config
field (defaulting to "Gatus" for backwards compatibility) that supports
the [ENDPOINT_NAME], [ENDPOINT_GROUP] and [ALERT_DESCRIPTION] placeholders,
matching the templating convention used by other providers such as clickup.

Closes #1387

Co-authored-by: Chris (ChrisJr404) <11917633+ChrisJr404@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2026-09-08 17:47:19 -04:00
4d15cb7eed chore(deps): bump golang.org/x/net to v0.58.0 to fix build on Go 1.27+ (#1787)
Symptom: building gatus (v5.36.0 and master) with Go 1.27.0 fails on
every platform with:

  # google.golang.org/grpc/internal/transport
  google.golang.org/grpc@v1.81.1/internal/transport/handler_server.go:271:18:
      undefined: http2.TrailerPrefix

Root cause: golang.org/x/net v0.54.0 (indirect dependency, pulled in via
grpc) started switching its http2 package to a shim that delegates to
the standard library's HTTP/2 server when built with Go >= 1.27. The
legacy implementation in http2/server.go, which declares the exported
const TrailerPrefix, is guarded by the build constraint
"!(go1.27 && !http2legacy)", so under Go 1.27+ it is excluded and
replaced by http2/server_wrap.go ("go1.27 && !http2legacy"). The
wrap-mode file set in v0.54.0 forgot to declare TrailerPrefix, so any
importer referencing http2.TrailerPrefix no longer compiles; grpc
v1.81.1 references it in internal/transport/handler_server.go:271.

The failure is Go-version-specific, not architecture-specific: it
reproduces identically on a linux/amd64 host build and on
GOOS=linux GOARCH=arm GOARM=7 and GOARCH=arm64 cross builds (it was
originally reported from a linux/arm SBC, which is incidental). Go 1.26
and older are unaffected because the go1.27 build tag is not set there
and the legacy implementation still compiles.

Fix: x/net v0.55.0 moved TrailerPrefix to http2/server_common.go, which
builds in both legacy and wrap mode, restoring the exported API. This
commit bumps golang.org/x/net to the current latest, v0.58.0, via
"go get golang.org/x/net@latest && go mod tidy", which also lifts the
sibling golang.org/x/* modules (crypto, mod, sync, sys, text, tools) to
their matching release tags. No grpc bump is required: v1.81.1 compiles
fine once the symbol exists again. go.mod/go.sum only, no code changes.

Verification:
- Go 1.27.0 (GOTOOLCHAIN=local): go build ./..., go vet ./... and
  go test ./... pass on linux/amd64; CGO_ENABLED=0 cross builds for
  GOOS=linux GOARCH=arm GOARM=7 and GOOS=linux GOARCH=arm64 succeed.
  Before this change, all of the above failed with the error quoted at
  the top.
- Go 1.26.3: go build ./... and go test ./... still pass, so older
  toolchains keep working.

This commit was prepared by an AI agent (Claude Fable 5, model id
claude-fable-5).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 17:05:18 -04:00
Vlad ShulczandGitHub 08c463542f docs: update the postgres bind mount in the compose example (#1761)
Postgres 18 moved its declared volume to /var/lib/postgresql, so the
example container fails to start with the old path.

Signed-off-by: vshulcz <vshulcz@gmail.com>
2026-08-17 21:22:58 -04:00
0fd2520088 docs(alerting): Revise HomeAssistant alerts and add Home Assistant integration info (#1723)
* Revise HomeAssistant alerts and add integration info

Updated HomeAssistant alerts section to indicate legacy status and added Home Assistant integration details.

* Fix typo

* Tweak wording from Legacy to Native

* Tweak on feedback

---------

Co-authored-by: TwiN <twin@linux.com>
2026-08-14 22:09:04 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7a73be52c5 chore(deps): bump docker/login-action from 4.5.2 to 4.6.0 (#1745)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.5.2 to 4.6.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v4.5.2...v4.6.0)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-08 22:51:58 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
73381e4179 chore(deps): bump docker/login-action from 4 to 4.5.2 (#1739)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4 to 4.5.2.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v4...v4.5.2)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.5.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 21:23:33 -04:00
b7100620c3 feat(client): Allow configuration of client cookie storage (#1716)
* Allow configuration of cookie storage

* Update client/config.go

---------

Co-authored-by: TwiN <twin@linux.com>
2026-07-25 17:12:10 -04:00
ae7ca199aa fix(jsonpath): prevent panic on negative array index (#1703)
A jsonpath condition with a negative array index (e.g. [BODY].data[-1]
or [BODY][-1]) reached array[arrayIndex] with a negative index because
the len(array) > arrayIndex bounds checks are satisfied by any negative
value, causing a 'runtime error: index out of range [-1]' panic. Since
condition evaluation runs in the watchdog with no recover(), this would
crash the process.

Guard against negative indices alongside the existing strconv.Atoi error
check so an out-of-range negative index returns nil (treated as an
invalid path) instead of panicking. Adds tests for negative indices on
keyed arrays, root arrays, and negative-index-followed-by-key.

Made by an AI agent (Claude Code, model Claude Opus 4.8).

Co-authored-by: TwiN <twin@linux.com>
2026-07-18 22:45:34 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
53392dd472 chore(deps): bump actions/checkout from 5 to 7 (#1694)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-09 22:44:11 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiNcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
4566e746f3 chore(deps): bump golang.org/x/crypto from 0.51.0 to 0.52.0 (#1675)
* chore(deps): bump golang.org/x/crypto from 0.51.0 to 0.52.0

Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.51.0 to 0.52.0.
- [Commits](https://github.com/golang/crypto/compare/v0.51.0...v0.52.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* Stabilize flaky network-dependent tests

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-09 20:30:22 -04:00
08895200bb feat(dns): Support TXT query type (#1679)
Allows monitoring of things like spf records and others.

Co-authored-by: TwiN <twin@linux.com>
2026-06-05 22:00:42 -04:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
809e5068d7 ci: Remove codecov from repository (#1684)
* Remove codecov from repository

* Remove .github/codecov.yml since Codecov integration was removed (#1685)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-03 21:16:08 -04:00
TwiNandGitHub ed1107b41a chore(deps): Bump all Go dependencies (#1674)
* chore(deps): Bump all Go dependencies

* test: Use httptest.NewRequest instead of http.NewRequest
2026-05-19 19:52:26 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
154ee6e055 chore(deps): bump codecov/codecov-action from 6.0.0 to 6.0.1 (#1672)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 6.0.0 to 6.0.1.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v6.0.0...v6.0.1)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 19:25:28 -04:00
9095a5254e chore(deps): update lib/pq to v1.11.2 to fix Supabase PostgreSQL connection regression (#1644)
* fix: update lib/pq to v1.11.2 to fix Supabase PostgreSQL connection regression

lib/pq v1.11.0 introduced a Config struct refactor that unconditionally
sends an empty 'options' parameter during the PostgreSQL startup handshake.
Supabase's Supavisor connection pooler rejects startup messages containing
empty parameters, returning :bad_startup_payload and causing an EOF panic
during Gatus initialization.

This bump to lib/pq v1.11.2 (commit 1412805) fixes the issue by omitting
empty startup parameters from the handshake.

Fixes TwiN/gatus#1633
See: https://github.com/lib/pq/issues/1259

Changes:
- go.mod: bump github.com/lib/pq v1.11.1 -> v1.11.2
- go.sum: update checksums for lib/pq v1.11.2
- storage/store/sql/supabase_test.go: add regression test

This contribution was AI-assisted.

* Remove regression test per maintainer feedback

@TwiN requested removal of the test - version bumps rarely need regression tests since they don't get bumped back down.

---------

Co-authored-by: Wahaj Ahmed <wahajahmed010@gmail.com>
Co-authored-by: TwiN <twin@linux.com>
2026-05-15 18:34:34 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fba833f64b chore(deps): bump modernc.org/sqlite from 1.48.2 to 1.50.0 (#1647)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.48.2 to 1.50.0.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.48.2...v1.50.0)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.50.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-30 19:43:06 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
834bfab5c5 chore(deps): bump github.com/aws/aws-sdk-go-v2/credentials from 1.19.13 to 1.19.15 (#1645)
chore(deps): bump github.com/aws/aws-sdk-go-v2/credentials

Bumps [github.com/aws/aws-sdk-go-v2/credentials](https://github.com/aws/aws-sdk-go-v2) from 1.19.13 to 1.19.15.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/credentials/v1.19.13...credentials/v1.19.15)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/credentials
  dependency-version: 1.19.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 20:55:40 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f9e03cb2c3 chore(deps): bump codecov/codecov-action from 5.5.3 to 6.0.0 (#1601)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.5.3 to 6.0.0.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5.5.3...v6.0.0)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 21:39:07 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
760b75348e chore(deps): bump modernc.org/sqlite from 1.47.0 to 1.48.2 (#1627)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.47.0 to 1.48.2.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.47.0...v1.48.2)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.48.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 21:36:58 -04:00
SurDaft - Jack StuppleandGitHub 447c7ad321 docs: Document remote.client configuration (#1637)
chore(docs): Document remote.client configuration

Document remote.client configuration for ticket #64.
2026-04-22 22:40:37 -04:00
osalloumandGitHub bc433161d6 fix(storage): close rows before nested queries to prevent PostgreSQL driver error (#1503)
Fixes #1435
2026-04-18 20:19:13 -04:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>TwiN
42b51f5da5 fix(test): Remove t.Parallel() from tests racing on injectedHTTPClient (#1630)
Remove t.Parallel() from tests causing race condition on injectedHTTPClient

Agent-Logs-Url: https://github.com/TwiN/gatus/sessions/bd8f2b87-d453-4653-a61d-4bf4dda4024b

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: TwiN <15699766+TwiN@users.noreply.github.com>
2026-04-18 20:08:59 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
48056def12 chore(deps): bump google.golang.org/api from 0.265.0 to 0.273.1 (#1613)
Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.265.0 to 0.273.1.
- [Release notes](https://github.com/googleapis/google-api-go-client/releases)
- [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md)
- [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.265.0...v0.273.1)

---
updated-dependencies:
- dependency-name: google.golang.org/api
  dependency-version: 0.273.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-11 22:54:38 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f06c4f132b chore(deps): bump actions/github-script from 8 to 9 (#1624)
Bumps [actions/github-script](https://github.com/actions/github-script) from 8 to 9.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v8...v9)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-10 22:13:35 -04:00
TwiN 64116ce3da docs(ai): Add AGENTS.md 2026-04-03 13:48:31 -04:00
Héctor LuacesGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>TwiN
12be9facda feat(ui): Add customizable OIDC login screen branding (#1583)
* feat(ui): Add customizable OIDC login screen branding

As per https://github.com/TwiN/gatus/discussions/1579, this PR
allows for some customizations in the OIDC login screen:

- If a logo is set it will be displayed alongside the Gatus one
- New ui config `ui.login-subtitle`: customises the message in
  the screen
- If set, use `ui.header` will be used to customise the title

I haven't commited the web static assets. I believe that's
triggered with a comment? let me know if I should be doing it.

Also please clarify if I've forgotten about something here,
this is my first pr.

* chore(ui): Regenerate static assets

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2026-04-01 20:56:47 -04:00
CopilotGitHubTwiNcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
f925cb4924 fix(ci): regenerate-static-assets workflow reporting false errors on success (#1614)
* Initial plan

* Fix workflow logic to properly handle success status

Agent-Logs-Url: https://github.com/TwiN/gatus/sessions/16d5eaf0-8b26-4f8e-8818-f245652fb022

Co-authored-by: TwiN <15699766+TwiN@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: TwiN <15699766+TwiN@users.noreply.github.com>
2026-04-01 20:45:20 -04:00
lifandGitHub f4428aa600 fix(client): return error from rdapQuery when no expiration event found (#1603)
* fix: return error from rdapQuery when no expiration event found

When an RDAP response has no expiration event, rdapQuery returned a
zero-value ExpirationDate with nil error. This prevented
GetDomainExpiration from falling back to WHOIS, causing failures
for TLDs like .net where RDAP may omit the expiration event.

Signed-off-by: majiayu000 <1835304752@qq.com>

* fix: include hostname in RDAP error and add .net domain tests

Add .net domain test cases to TestRdapQuery and TestGetDomainExpiration
to cover the fallback path when RDAP lacks an expiration event (#1570).
Include hostname in the error message for easier debugging.

Signed-off-by: majiayu000 <1835304752@qq.com>

---------

Signed-off-by: majiayu000 <1835304752@qq.com>
2026-03-31 20:02:04 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0e390765e3 chore(deps): bump github.com/aws/aws-sdk-go-v2/credentials from 1.19.7 to 1.19.13 (#1608)
chore(deps): bump github.com/aws/aws-sdk-go-v2/credentials

Bumps [github.com/aws/aws-sdk-go-v2/credentials](https://github.com/aws/aws-sdk-go-v2) from 1.19.7 to 1.19.13.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/m2/v1.19.7...credentials/v1.19.13)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/credentials
  dependency-version: 1.19.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-31 18:54:45 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
893601a5f4 chore(deps): bump golang.org/x/crypto from 0.48.0 to 0.49.0 (#1599)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.48.0 to 0.49.0.
- [Commits](https://github.com/golang/crypto/compare/v0.48.0...v0.49.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 23:24:55 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
322f278e8f chore(deps): bump modernc.org/sqlite from 1.46.1 to 1.47.0 (#1598)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.46.1 to 1.47.0.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.46.1...v1.47.0)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-25 22:48:17 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
814a9622c4 chore(deps): bump golang.org/x/oauth2 from 0.35.0 to 0.36.0 (#1595)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.35.0 to 0.36.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.35.0...v0.36.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  dependency-version: 0.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-24 21:25:02 -04:00
PythonGermanyandGitHub a2fc5ab9bb fix(security): Do not allow invalid security configs (#1531)
* fix: Do not allow invalid security configs

* test: Fix empty security config test

* test: Add missing security config test scenarios
2026-03-23 17:35:42 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
593826ec5f chore(deps): bump golang.org/x/crypto from 0.47.0 to 0.48.0 (#1547)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.47.0 to 0.48.0.
- [Commits](https://github.com/golang/crypto/compare/v0.47.0...v0.48.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.48.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-23 16:54:21 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
9b2efa290f chore(deps): bump modernc.org/sqlite from 1.44.3 to 1.46.1 (#1554)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.44.3 to 1.46.1.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.44.3...v1.46.1)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.46.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-23 16:54:03 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
714143204b chore(deps): bump codecov/codecov-action from 5.5.2 to 5.5.3 (#1587)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.5.2 to 5.5.3.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5.5.2...v5.5.3)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 5.5.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-23 16:53:45 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ff396b5079 chore(deps): bump docker/build-push-action from 6 to 7 (#1588)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-23 16:53:26 -04:00
jaydeethreeandGitHub b20935ecfe fix(alerting): Remove square brackets from email subject (#1586)
* Email alerts: Use parens instead of square brackets in the subject to resolve Gmail threading issues. Fixes #1563

* Remove parens

* Add `:` after alert name
2026-03-21 21:25:52 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3a6ad14cba chore(deps): bump docker/login-action from 3 to 4 (#1567)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-18 17:05:15 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
e6e211c916 chore(deps): bump docker/setup-qemu-action from 3 to 4 (#1566)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2026-03-18 16:42:20 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7cd09e7d03 chore(deps): bump docker/metadata-action from 5 to 6 (#1571)
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-18 16:37:51 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fa8a387c9f chore(deps): bump docker/setup-buildx-action from 3 to 4 (#1572)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-18 16:37:36 -04:00
c7f0b85ba1 perf: Add PostgreSQL indexes for ~15x performance improvement (#1541)
fix: missing indexes are added to improve postgresql performance

Co-authored-by: TwiN <twin@linux.com>
2026-02-24 23:39:07 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
c4900ced6c chore(deps): bump golang.org/x/oauth2 from 0.34.0 to 0.35.0 (#1543)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.34.0 to 0.35.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.34.0...v0.35.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  dependency-version: 0.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2026-02-19 22:31:01 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
433e59b0ce chore(deps): bump github.com/prometheus-community/pro-bing from 0.7.0 to 0.8.0 (#1544)
chore(deps): bump github.com/prometheus-community/pro-bing

Bumps [github.com/prometheus-community/pro-bing](https://github.com/prometheus-community/pro-bing) from 0.7.0 to 0.8.0.
- [Release notes](https://github.com/prometheus-community/pro-bing/releases)
- [Commits](https://github.com/prometheus-community/pro-bing/compare/v0.7.0...v0.8.0)

---
updated-dependencies:
- dependency-name: github.com/prometheus-community/pro-bing
  dependency-version: 0.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-09 20:11:15 -05:00
f09d959c97 refactor: Simplify and modernize loops (#1522)
* refactor: Simplify loops

* refactor: Modernize loops using range over int

---------

Co-authored-by: TwiN <twin@linux.com>
2026-02-08 19:08:59 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
e5b4e0d381 chore(deps): bump google.golang.org/api from 0.264.0 to 0.265.0 (#1533)
Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.264.0 to 0.265.0.
- [Release notes](https://github.com/googleapis/google-api-go-client/releases)
- [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md)
- [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.264.0...v0.265.0)

---
updated-dependencies:
- dependency-name: google.golang.org/api
  dependency-version: 0.265.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2026-02-07 22:27:02 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e580cd362a chore(deps): bump github.com/TwiN/whois from 1.2.0 to 1.3.0 (#1538)
Bumps [github.com/TwiN/whois](https://github.com/TwiN/whois) from 1.2.0 to 1.3.0.
- [Release notes](https://github.com/TwiN/whois/releases)
- [Commits](https://github.com/TwiN/whois/compare/v1.2.0...v1.3.0)

---
updated-dependencies:
- dependency-name: github.com/TwiN/whois
  dependency-version: 1.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-06 17:24:24 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b65b4d83e1 chore(deps): bump code.gitea.io/sdk/gitea from 0.22.1 to 0.23.2 (#1525)
Bumps code.gitea.io/sdk/gitea from 0.22.1 to 0.23.2.

---
updated-dependencies:
- dependency-name: code.gitea.io/sdk/gitea
  dependency-version: 0.23.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-03 15:56:35 -05:00
emrebasarannnandGitHub 618892c248 docs: Fix typo in README concurrency section (#1520)
fix(typo): Fixing typo in 'README.md/Concurrency' section
2026-02-01 10:43:18 -05:00
TwiNandGitHub 77cedb080d chore: Update to Go 1.25 (#1519) 2026-01-31 18:30:38 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
786af2dee4 chore(deps): bump github.com/aws/aws-sdk-go-v2/credentials from 1.19.2 to 1.19.7 (#1498)
chore(deps): bump github.com/aws/aws-sdk-go-v2/credentials

Bumps [github.com/aws/aws-sdk-go-v2/credentials](https://github.com/aws/aws-sdk-go-v2) from 1.19.2 to 1.19.7.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Changelog](https://github.com/aws/aws-sdk-go-v2/blob/main/changelog-template.json)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/m2/v1.19.2...service/m2/v1.19.7)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/credentials
  dependency-version: 1.19.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2026-01-26 22:00:24 -05:00
48531911bb fix: handle HTTP header names case-insensitively (#1506)
Details: HTTP header names should be case-insensitive per RFC 7230. When the users
configure the headers like 'user-agent' (lowercase), gatus was adding its own 'User-Agent'
header because the case-sensitive map lookup failed to find the existing header.

Fixed headers:
- User-Agent
- Content-Type
- Host

Fixes #1491"

Co-authored-by: TwiN <twin@linux.com>
2026-01-24 21:49:16 -05:00
CopilotGitHubTwiNcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
720888009e test: Fix Gitea error handling in tests (#1508)
* Initial plan

* Fix Gitea test failures by handling authentication and network errors

Co-authored-by: TwiN <15699766+TwiN@users.noreply.github.com>

* Refactor: Extract error checking logic into helper function

Co-authored-by: TwiN <15699766+TwiN@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: TwiN <15699766+TwiN@users.noreply.github.com>
2026-01-24 21:42:50 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
961bffd8e7 chore(deps): bump github.com/miekg/dns from 1.1.68 to 1.1.70 (#1490)
Bumps [github.com/miekg/dns](https://github.com/miekg/dns) from 1.1.68 to 1.1.70.
- [Commits](https://github.com/miekg/dns/compare/v1.1.68...v1.1.70)

---
updated-dependencies:
- dependency-name: github.com/miekg/dns
  dependency-version: 1.1.70
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-19 23:01:15 -05:00
Mateusz ŁoskotandGitHub 309d68e622 feat(ui): Add support for custom favicon (#1097)
feat: Add UI configuration for custom favicon

Adds new node `favicon` to `ui` configuration
with properties that allow to overwrite the three
favicons which are currently in use in the index.html template.

Closes #174

Signed-off-by: Mateusz Łoskot <mateusz@loskot.net>
2026-01-16 17:27:39 -05:00
TwiNandGitHub ce269905b7 ci: Fix typo in regenerate-static-assets comment 2026-01-16 17:26:26 -05:00
TwiNandGitHub d8724d683e ci: Improve regenerate-static-assets comments 2026-01-16 17:24:57 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0f22e863a9 chore(deps): bump golang.org/x/crypto from 0.45.0 to 0.46.0 (#1488)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.45.0 to 0.46.0.
- [Commits](https://github.com/golang/crypto/compare/v0.45.0...v0.46.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 23:29:05 -05:00
Lorenzo Pereira Piccoli XavierandGitHub 7bb959e072 feat(conditions,ui): Add endpoints[].ui.resolve-successful-conditions (#1486)
feat(ui&endpoints): add bolean option for show resolve-successful-conditions
2026-01-11 22:36:23 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
1095deb3c6 chore(deps): bump github.com/aws/aws-sdk-go-v2 from 1.40.0 to 1.41.0 (#1466)
Bumps [github.com/aws/aws-sdk-go-v2](https://github.com/aws/aws-sdk-go-v2) from 1.40.0 to 1.41.0.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Changelog](https://github.com/aws/aws-sdk-go-v2/blob/main/changelog-template.json)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/v1.40.0...v1.41.0)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2
  dependency-version: 1.41.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2026-01-11 18:59:37 -05:00
PythonGermanyandGitHub c93a321073 fix(ci): Adjust static asset generation workflow (#1480)
* fix(ci): Add comment workflow has no changes to commit

* refactor: Use github icon syntax

* feat(ci): Mention user who triggered the workflow

* fix: Get actor from context

* fix: Update incorrect variable name

* feat(ci): Add workflow link for all comments
2026-01-09 22:55:33 -05:00
PythonGermanyGitHubTwiNgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
1bf05d6bf9 fix(ui): Persist show average response time setting (#1438)
* feat(ui): Persist show average response time

* chore(ui): Regenerate static assets

---------

Co-authored-by: TwiN <twin@linux.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-01-08 21:27:44 -05:00
1c532971cb ui: Disable hover effect if no link is set (#1419)
* fix(ui): Disable hover effect if no link is set

* refactor: Simplify conditional class logic

* chore(ui): Regenerate static assets

* chore(ui): Regenerate static assets

---------

Co-authored-by: TwiN <twin@linux.com>
2026-01-02 20:31:19 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
67bf6c16b6 chore(deps): bump google.golang.org/grpc from 1.75.1 to 1.77.0 (#1465)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.75.1 to 1.77.0.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.75.1...v1.77.0)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.77.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-02 19:59:48 -05:00
Luke TaintonandGitHub c94c9fa36a docs(alerting): Remove warning for Splunk alerting provider (#1475)
Remove warning for Splunk alerting provider

Removed warning about untested Splunk alerting provider from README.
2025-12-31 16:46:27 -05:00
PythonGermanyandGitHub 9587e5db8a docs: Update config section and add env var faq (#1450)
* docs: Add faq for environment variables in config giles

* docs: Move config infos to config section

* docs: Extend config env var example

* docs: Remove env var escape from example
2025-12-29 22:07:33 -05:00
2d5f0a5927 feat(alerting): ClickUp alerting provider (#1462)
* feat(alerting): Add ClickUp alerting provider

* test: added ClickUp tests, docs in README and switch http to github.com/TwiN/gatus/v5/client from http.Client

* docs: Update ClickUp API token instructions in README

* fix(alerting): Update ClickUp alert configuration and default values

* docs: fixed formatting and removed line breaks from content in README

* feat(alerting): Add group-specific overrides and validation checks, updated README

* Update alerting/provider/clickup/clickup.go

Co-authored-by: PythonGermany <97847597+PythonGermany@users.noreply.github.com>

* fix(alerting): add priority validation

* fix(alerting): set default priority to 3

* feat(alerting): add priority map for ClickUp provider

* feat(alerting): make notify-all configurable for ClickUp provider

* docs: add instructions for finding Assignee IDs for ClickUp

* refactor(alerting): simplified ClickUp configurations example

* refactor(alerting): clean up new lines  in ClickUp provider

---------

Co-authored-by: PythonGermany <97847597+PythonGermany@users.noreply.github.com>
2025-12-26 19:39:28 -05:00
PythonGermanyandGitHub 6c8761ca35 docs: Add missing alert provider group override options (#1467) 2025-12-22 18:43:47 -05:00
40b1576ec7 docs: Separate web and ui config into sections (#1439)
Co-authored-by: TwiN <twin@linux.com>
2025-12-21 20:59:04 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
64c3b12a7b chore(deps): bump code.gitea.io/sdk/gitea from 0.22.0 to 0.22.1 (#1410)
Bumps code.gitea.io/sdk/gitea from 0.22.0 to 0.22.1.

---
updated-dependencies:
- dependency-name: code.gitea.io/sdk/gitea
  dependency-version: 0.22.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-21 20:54:35 -05:00
PythonGermanyandGitHub 138f5bfb76 ci: Add workflow to regenerate static assets (#1457)
* feat(ci): Add workflow to regenerate static assets

* feat(ci): Use command action handle command triger

* fix(ci): Only give success response after commiting

* fix(ci): Only run for pr comments

* refactor: Update command trigger text

* refactor: Explicitly list permission levels allowed

* chore(ci): Allow regenerate command on draft prs
2025-12-21 19:47:51 -05:00
15a8055617 fix(client): Switch websocket library (#1423)
* fix(websocket): switch to gorilla/websocket

* fix(client): add missing t.Parallel() in tests

---------

Co-authored-by: TwiN <twin@linux.com>
2025-12-18 18:44:44 -05:00
13184232d1 fix(ui): Inconsistent time values in UI (#1452)
* fix(ui): Truncate displayed time values

* refactor(ui): Use util function

* chore(ui): Regenerate static assets

---------

Co-authored-by: TwiN <twin@linux.com>
2025-12-18 18:22:40 -05:00
TwiN d0cca91043 test: Update expectedBody for DNS test 2025-12-17 18:33:57 -05:00
PythonGermanyandGitHub 239d1f5118 chore(ui): Remove unnecessary eslint rule disables (#1422)
cleanup(ui): Remove unnecessary eslint rule disables
2025-12-16 16:03:24 -05:00
Glib ShpychkaandGitHub 47bc78dc25 docs: Update Telegram User ID to Chat ID in README (#1434)
fix(docs): Update Telegram User ID to Chat ID in README
2025-12-16 16:00:04 -05:00
PythonGermanyandGitHub 1df0801a61 ci: Add platform input for custom action workflow (#1437) 2025-12-11 16:17:28 -05:00
TwiN d42c5f899e chore(ui): Regenerate static assets 2025-12-10 20:45:06 -05:00
5f4c26e5fe fix(ui): Do not store config locally on load (#1432)
* fix(ui): Do not store config locally on load

* chore(ui): Regenerate static assets

---------

Co-authored-by: TwiN <twin@linux.com>
2025-12-10 19:12:54 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2beaca5700 chore(deps): bump codecov/codecov-action from 5.5.1 to 5.5.2 (#1428)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.5.1 to 5.5.2.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5.5.1...v5.5.2)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 5.5.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-10 16:54:33 -05:00
PythonGermanyandGitHub a55eb7da40 refactor(ui): Improve dev environment (#1429) 2025-12-10 16:29:46 -05:00
PythonGermanyandGitHub b0629773e5 build(docker): Non tidy go mods fail image build (#1418)
fix(docker): Non tidy go mods fail image build
2025-12-06 17:48:39 -05:00
PythonGermanyandGitHub c5f7e5b82b refactor(docker): Update compose files (#1409)
* refactor(docker): Rename compose files

* refactor(docker): Remove obsolete version attribute
2025-12-01 20:15:46 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a2a7e1f14a chore(deps): bump github.com/aws/aws-sdk-go-v2/config from 1.31.12 to 1.31.15 (#1366)
chore(deps): bump github.com/aws/aws-sdk-go-v2/config

Bumps [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) from 1.31.12 to 1.31.15.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Changelog](https://github.com/aws/aws-sdk-go-v2/blob/main/changelog-template.json)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.31.12...config/v1.31.15)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.31.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-28 20:28:12 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
1e4c440f01 chore(deps): bump github.com/aws/aws-sdk-go-v2/service/ses from 1.34.5 to 1.34.7 (#1365)
chore(deps): bump github.com/aws/aws-sdk-go-v2/service/ses

Bumps [github.com/aws/aws-sdk-go-v2/service/ses](https://github.com/aws/aws-sdk-go-v2) from 1.34.5 to 1.34.7.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Changelog](https://github.com/aws/aws-sdk-go-v2/blob/main/changelog-template.json)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/mq/v1.34.5...service/ses/v1.34.7)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/ses
  dependency-version: 1.34.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2025-11-28 20:16:53 -05:00
TwiN 844847bb05 chore(ui): Regenerate static assets 2025-11-28 19:57:06 -05:00
0c3231713f fix(ui): Show correct avg response time for N/A value (#1407)
* fix(ui): Show correct avg response time not applicable value

* refactor(ui): Convert to milliseconds after loop

---------

Co-authored-by: TwiN <twin@linux.com>
2025-11-28 19:56:02 -05:00
ee01adb603 fix(ui): Show correct oldest result timestamp (#1405)
* fix(ui): Show correct oldest result timestamp

* fix(ui): Request correct result page size in home view

* refactor(ui): Use constant for result page size

---------

Co-authored-by: TwiN <twin@linux.com>
2025-11-28 19:05:39 -05:00
TwiN 9121d87965 fix(ui): Typo in conditional check if dashboard subheading is not set and running in dev mode 2025-11-27 20:55:30 -05:00
Bo-Yi WuandGitHub 86cd1a9eb2 chore(deps): update Go module dependencies for CVE security (#1402)
chore(mod): update Go module dependencies for CVE security

- Update golang.org/x/crypto, golang.org/x/net, and golang.org/x/sync dependencies to newer versions
- Bump indirect dependencies golang.org/x/mod, golang.org/x/sys, golang.org/x/text, and golang.org/x/tools to latest releases

* CVE-2025-47914 and CVE-2025-58181

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-11-27 17:16:03 -05:00
Giorgio GalloandGitHub 9f960fdd27 docs: complete unfinished sentence in README.md (#1400)
chore: complete unfinished sentence in README.md

Fixes #1398
2025-11-25 22:58:19 -05:00
6f9db4107c feat(client): Add ssh private-key support (#1390)
* feat(endpoint): Add ssh key support

Fixes #1257

* test(config): Add tests for private key config

---------

Co-authored-by: TwiN <twin@linux.com>
2025-11-19 16:36:36 -05:00
TwiN 5d626f2934 test(ui): Improve validation tests for UI config 2025-11-16 15:41:25 -05:00
75c1b290f6 feat(ui): customizable dashboard heading and subheading (#1235)
* Made the Dashboard Text customizable

* Aligned with spaces, changed feature name to DashboardHeading and DashboardSubheading

* rebuild frontend

---------

Co-authored-by: macmoritz <tratarmoritz@gmail.com>
2025-11-16 15:33:26 -05:00
GiampaoloandGitHub fe7b74f555 docs: Remove ECS Fargate section from README (#1389)
Remove ECS Fargate section from README

Removed ECS Fargate deployment section from README.
2025-11-12 07:26:47 -05:00
Zee AslamandGitHub ed4c270a25 docs: Add note to README.md regarding CAP_NET_RAW (#1384)
* docs: Add note to README.md regarding CAP_NET_RAW

Signed-off-by: Zee Aslam <zeet6613@gmail.com>

* docs: fix inconsistent markdown

Signed-off-by: Zee Aslam <zeet6613@gmail.com>

---------

Signed-off-by: Zee Aslam <zeet6613@gmail.com>
2025-11-09 15:50:24 -05:00
TwiN 379ec2983d refactor(announcements): Move duplicate markdown code into utils/markdown.js 2025-11-08 13:54:36 -05:00
SworyzandGitHub 907716289c feat(announcements): add markdown support (#1371)
* feat(announcements): add markdown support

* feat(announcements): add information about announcement formatting in readme

* feat(announcements): bump packages versions for marked and dompurify

* feat(announcements): bump versions for marked and dompurify in package-lock.json

* fix(announcements): md to link was not working since the conflict merge

* fix(announcements): fix time before message and not after

* feat(announcements): past announcements add markdown support

* feat(announcements): static files
2025-11-08 13:28:57 -05:00
TwiN 7c6b5539c1 docs: Add past-announcements.jpg 2025-11-07 19:42:48 -05:00
TwiNandGitHub 607f3c5549 feat(announcements): Add support for archived announcements and add past announcement section in UI (#1382)
* feat(announcements): Add support for archived announcements and add past announcement section in UI

* Add missing field
2025-11-07 19:35:39 -05:00
Arden RasmussenandGitHub 9e97efaba1 fix(api): Escape endpoint key in URL for raw APIs (#1381) 2025-11-07 18:20:10 -05:00
diamanatandGitHub 8912b4b3e3 feat(client): Add support for monitoring gRPC endpoints (#1376)
* add grpc

* add gRPC to readme
2025-11-07 15:19:13 -05:00
5fdc489113 fix(client): update icmp/ping logic to determine pinger privileged mode (#1346)
* fix(pinger): update logic to determine pinger privileged mode

* add some unit tests for pinger

Signed-off-by: Zee Aslam <zeet6613@gmail.com>

* undo accidental removal

Signed-off-by: Zee Aslam <zeet6613@gmail.com>

* check for cap_net_raw by trying to open a raw socket and checking for permission error

Signed-off-by: Zee Aslam <zeet6613@gmail.com>

* revert syscall after testing. It is unable to build a binary on windows

Signed-off-by: Zee Aslam <zeet6613@gmail.com>

* remove extra import

* review icmp section of readme. No changes required

Signed-off-by: Zee Aslam <zeet6613@gmail.com>

* Update client/client.go

Co-authored-by: TwiN <twin@linux.com>

* Update client/client.go

Match function name

Co-authored-by: TwiN <twin@linux.com>

* Update client/client.go

Remove extra line

Co-authored-by: TwiN <twin@linux.com>

---------

Signed-off-by: Zee Aslam <zeet6613@gmail.com>
Co-authored-by: TwiN <twin@linux.com>
2025-11-04 20:42:20 -05:00
GiampaoloandGitHub 2ebb74ae1e docs: Add ECS fargate module in README (#1377) 2025-11-02 00:04:28 -04:00
TwiNandGitHub e2f06e9ede fix(ui): Modernize response time chart (#1373) 2025-10-29 14:15:59 -04:00
TwiNandGitHub beb9a2f3d9 feat(condition): Format certificate and domain expiration durations in human-readable format (#1370) 2025-10-27 16:30:12 -04:00
MaxandGitHub e469b6adf4 fix(external-endpoint): check per-endpoint maintenance windows (#1369)
* fix(external-endpoint): check per-endpoint maintenance windows

* refactor(external-endpoint): use tabs for indentation
2025-10-27 12:21:20 -04:00
2f8a3d2a02 feat(metrics): Add metrics for domain expiration (#1244)
* Add metrics for domain expiration

* Update grafana and prometheus versions and extend grafana dashboard with Domain expiration

* feat(deps) update whois version

---------

Co-authored-by: TwiN <twin@linux.com>
2025-10-25 12:45:15 -04:00
TwiNandGitHub 9495b7389e feat(ui): Add support for endpoints[].ui.hide-errors to hide errors (#1361)
Supersedes #1292
2025-10-22 13:13:53 -04:00
TwiN c8bdecbde8 docs: Fix ordering in Table of Contents 2025-10-21 14:11:00 -04:00
TwiNandGitHub 394602bc47 fix(ui): Ensure retrievedConfig is set to true after fetching configuration (#1359) 2025-10-21 08:06:02 -04:00
TwiNandGitHub 15813d4297 fix(client): Add nil check for SSHConfig before validating username/password (#1358)
Fixes #1357
2025-10-21 07:37:24 -04:00
TwiNandGitHub d24c66cf96 fix(key): Revert support for ( and ) as name/group, as they already worked before (#1356)
Relevant: #1340
2025-10-20 13:50:02 -04:00
TwiNandGitHub 70d7d0c54c fix(suites): Load persisted triggered alerts for suite endpoints on start (#1347) 2025-10-20 13:31:58 -04:00
TwiN 91931e48b4 fix(ui): Clear selected result before toggling new one
relevant: #1236
2025-10-17 22:06:54 -04:00
TwiN 386a4d2cb7 fix(ui): Implement toggleable tooltip for suites too
relevant: #1236
2025-10-17 21:41:40 -04:00
TwiN 4d9eb0572c docs(alerting): Link n8n-nodes-gatus-trigger for n8n alerting provider 2025-10-17 21:12:53 -04:00
1586b3cc0b feat(alerting): Add message-content parameter for Discord pings (#1335)
* feat(discord-alerts): add option for prefix-messages outside of embeds

* chore(docs): add discord prefix-message to README

* chore(discord-alerts): rename prefix-message to message-content

---------

Co-authored-by: TwiN <twin@linux.com>
2025-10-17 20:22:26 -04:00
981e082d0c feat(ui): Make tooltips toggleable (#1236)
* feat(results): allow for data points in checks to be "clicked"

asdf

* feat(ui): resolve merge conflicts

feat(dev): put back package.lock

* fix(ui): make sure the datapoint stays "fixed"

* fix(ui): watch for url changes to make tooltip go away

* feat(ui): add compiled app.css and app.js

* fix(ui): lengthen the tooltipElement name

---------

Co-authored-by: TwiN <twin@linux.com>
2025-10-17 16:09:47 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
91daaf92aa chore(deps): bump github.com/gofiber/fiber/v2 from 2.52.8 to 2.52.9 (#1338)
Bumps [github.com/gofiber/fiber/v2](https://github.com/gofiber/fiber) from 2.52.8 to 2.52.9.
- [Release notes](https://github.com/gofiber/fiber/releases)
- [Commits](https://github.com/gofiber/fiber/compare/v2.52.8...v2.52.9)

---
updated-dependencies:
- dependency-name: github.com/gofiber/fiber/v2
  dependency-version: 2.52.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-17 10:09:03 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a1bb07c556 chore(deps): bump golang.org/x/net from 0.45.0 to 0.46.0 (#1333)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.45.0 to 0.46.0.
- [Commits](https://github.com/golang/net/compare/v0.45.0...v0.46.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-17 10:08:48 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
258175dec3 chore(deps): bump code.gitea.io/sdk/gitea from 0.21.0 to 0.22.0 (#1341)
Bumps code.gitea.io/sdk/gitea from 0.21.0 to 0.22.0.

---
updated-dependencies:
- dependency-name: code.gitea.io/sdk/gitea
  dependency-version: 0.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-17 10:08:31 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
ef6159e420 chore(deps): bump github.com/prometheus/client_golang from 1.23.0 to 1.23.2 (#1337)
chore(deps): bump github.com/prometheus/client_golang

Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.23.0 to 1.23.2.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.23.0...v1.23.2)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-version: 1.23.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2025-10-16 20:45:36 -04:00
TwiNandGitHub ebd4068aac fix(key): Support (, ), + and & as name/group (#1340)
fix(key): Support (, ), + and & as name/group

Relevant: #1339
2025-10-16 16:47:11 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
39981de54b chore(deps): bump golang.org/x/crypto from 0.42.0 to 0.43.0 (#1332)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.42.0 to 0.43.0.
- [Commits](https://github.com/golang/crypto/compare/v0.42.0...v0.43.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.43.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-15 08:49:52 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7dce07e47f chore(deps): bump modernc.org/sqlite from 1.38.2 to 1.39.1 (#1331)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.38.2 to 1.39.1.
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.38.2...v1.39.1)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.39.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-15 08:23:57 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6a83857db4 chore(deps): bump github.com/coreos/go-oidc/v3 from 3.14.1 to 3.16.0 (#1313)
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.14.1 to 3.16.0.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.14.1...v3.16.0)

---
updated-dependencies:
- dependency-name: github.com/coreos/go-oidc/v3
  dependency-version: 3.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-14 16:42:57 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
50702bd1d4 chore(deps): bump google.golang.org/api from 0.242.0 to 0.252.0 (#1315)
Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.242.0 to 0.252.0.
- [Release notes](https://github.com/googleapis/google-api-go-client/releases)
- [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md)
- [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.242.0...v0.252.0)

---
updated-dependencies:
- dependency-name: google.golang.org/api
  dependency-version: 0.252.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-14 10:19:44 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5bf95fe4f7 chore(deps): bump github.com/valyala/fasthttp from 1.64.0 to 1.67.0 (#1330)
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.64.0 to 1.67.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.64.0...v1.67.0)

---
updated-dependencies:
- dependency-name: github.com/valyala/fasthttp
  dependency-version: 1.67.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-14 09:56:20 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
20d8ef966b chore(deps): bump golang.org/x/oauth2 from 0.30.0 to 0.32.0 (#1317)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.30.0 to 0.32.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.30.0...v0.32.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  dependency-version: 0.32.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-13 22:11:52 -04:00
michael-barabooandGitHub 8f15346fb7 fix(alerting)!: remove discontinued jetbrains space alerting provider (#1329)
remove alerting provider for discontinued jetbrains space
2025-10-13 20:47:05 -04:00
TwiNandGitHub 8920bdd301 fix(ui): Handle refresh properly on SuiteDetails.vue (#1324) 2025-10-12 14:27:43 -04:00
TwiN e37024dfc6 docs: Use working websocket example 2025-10-09 13:28:34 -04:00
TwiN ac4374b1e3 docs: Replace Docker Hub with GHCR as primary container registry 2025-10-06 14:15:14 -04:00
129fb82f71 feat(alerting): Add RESULT_CONDITIONS in custom alert to have more information (#1086)
feat(alerting): Add RESULT_CONDITIONS in custom alert to have more information on an alert while using custom alerting module

Add testing of new feature

Co-authored-by: TwiN <twin@linux.com>
2025-10-06 12:22:38 -04:00
Andrii OriekhovandGitHub 374be99b35 fix(alerting): Format link from Telegram alert description when sending message (#1200)
* allow passing Markdown link in telegram message

* update tests
2025-10-05 15:00:45 -04:00
5c78bd92fb feat(client): Support body placeholder for SSH endpoints (#1286)
* feat(ssh): Add BODY placeholder support for SSH endpoints

- Modify ExecuteSSHCommand to capture stdout output
- Update SSH endpoint handling to use needsToReadBody() mechanism
- Add comprehensive test cases for SSH BODY functionality
- Support basic body content, pattern matching, JSONPath, and functions
- Maintain backward compatibility with existing SSH endpoints

* docs: Add SSH BODY placeholder examples to README

- Add [BODY] placeholder to supported SSH placeholders list
- Add comprehensive examples showing various SSH BODY conditions
- Include pattern matching, length checks, JSONPath expressions
- Demonstrate function wrappers (len, has, any) usage

* Revert "docs: Add SSH BODY placeholder examples to README"

This reverts commit ae93e38683.

* docs: Add [BODY] placeholder to SSH supported placeholders list

* test: remove SSH BODY placeholder test cases

* Update client/client.go

* Update client/client.go

* docs: Add minimal SSH BODY example

---------

Co-authored-by: TwiN <twin@linux.com>
2025-10-03 22:52:34 -04:00
TwiNandGitHub 8853140cb2 feat(alerting): Add support for n8n alerts (#1309) 2025-10-03 16:51:26 -04:00
TwiNandGitHub 03ec18a703 fix(ui): Swap oldest/newest result time for SuiteCard.vue (#1308) 2025-10-03 13:36:09 -04:00
Kevin KuglerGitHubNerdySoftPawTwiNcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
65eaed4621 fix(incidentio): Implement deduplication key generation for alerts (#1296)
* fix(incidentio): Implement deduplication key generation for alerts

* fix(incidentio): Merge metadata from config and endpoint extra labels in request body

* fix(incidentio): Update comments for clarity and consistency in deduplication key generation and metadata merging

* fix(incidentio): Update comments for clarity and consistency in metadata merging and deduplication key generation

* fix(incidentio): Remove duplicate Metadata assignment in request body construction

* refactor(incidentio): Reformat code for consistency and readability in request body construction

* fix(incidentio): Remove unnecessary newline in buildRequestBody function

* Initial plan

* Fix incidentio tests to handle dynamic deduplication_key field

Co-authored-by: NerdySoftPaw <7468547+NerdySoftPaw@users.noreply.github.com>

---------

Co-authored-by: TwiN <twin@linux.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2025-10-03 13:33:51 -04:00
TwiNandGitHub 10c6e71eef chore(deps): Update aws-sdk-go to aws-sdk-go-v2 (#1305) 2025-10-03 13:33:37 -04:00
TwiN c7f0a32135 fix(tunneling): Adjust exponential backoff duration 2025-09-30 14:27:38 -04:00
TwiNandGitHub 405c15f756 fix(tunneling): Add exponential backoff retry (#1303) 2025-09-30 14:08:56 -04:00
TwiNandGitHub 6f1312dfcf chore: Tweak configuration validation and yaml output (#1302) 2025-09-30 13:38:17 -04:00
TwiNandGitHub bd296c75da chore: Export validation function (#1301) 2025-09-29 23:01:27 -04:00
TwiNandGitHub f007725140 fix(ui): Make sure EndpointCard aligns even if no group + hide-hostname (#1300) 2025-09-29 22:55:11 -04:00
TwiNandGitHub 40345a03d3 feat(client): Add support for SSH tunneling (#1298)
* feat(client): Add support for SSH tunneling

* Fix test
2025-09-28 14:26:12 -04:00
97a2be3504 fix(alerting): Added description block in teams-workflows (#1275)
* fix(alerting): Added description block in teams-workflows

* Update teamsworkflows_test.go

---------

Co-authored-by: TwiN <twin@linux.com>
2025-09-25 16:28:22 -04:00
TwiNandGitHub 15a4133502 fix(alerting): Limit minimum-reminder-interval to >5m (#1290) 2025-09-25 16:24:15 -04:00
RonandGitHub 64a5043655 docs(alerting): Remove SIGNL4 untested warning (#1289)
Update README.md

SIGNL4 warning removed. I have tested it and both, triggering and resolving of alerts work fine.
2025-09-24 06:33:57 -04:00
TwiNandGitHub 5a06a74cc3 fix(events): Retrieve newest events instead of oldest events (#1283)
Fixes #1040
2025-09-21 15:40:17 -04:00
TwiNandGitHub d6fa2c955b fix(suites): Handle invalid paths in store and update needsToReadBody to check store (#1282)
* fix(suites): Invalid path in store parameter should return an error

* Refactor

* fix(suites): Update needsToReadBody to check store mappings for body placeholders
2025-09-21 13:15:59 -04:00
145 changed files with 6507 additions and 2043 deletions
@@ -1,4 +1,3 @@
version: "3.9"
services:
gatus:
container_name: gatus
@@ -13,7 +12,7 @@ services:
prometheus:
container_name: prometheus
image: prom/prometheus:v2.14.0
image: prom/prometheus:v3.5.0
restart: always
command: --config.file=/etc/prometheus/prometheus.yml
ports:
@@ -25,7 +24,7 @@ services:
grafana:
container_name: grafana
image: grafana/grafana:6.4.4
image: grafana/grafana:12.1.0
restart: always
environment:
GF_SECURITY_ADMIN_PASSWORD: secret
@@ -16,4 +16,10 @@ endpoints:
url: https://api.github.com/healthz
interval: 5m
conditions:
- "[STATUS] == 200"
- "[STATUS] == 200"
- name: check-domain-expiration
url: "https://example.org/"
interval: 1h
conditions:
- "[DOMAIN_EXPIRATION] > 720h"
@@ -19,7 +19,7 @@
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 566,
"id": 41,
"links": [],
"panels": [
{
@@ -39,7 +39,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": 0
},
{
"color": "yellow",
@@ -79,7 +80,7 @@
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -122,7 +123,8 @@
"mode": "absolute",
"steps": [
{
"color": "red"
"color": "red",
"value": 0
},
{
"color": "yellow",
@@ -162,7 +164,7 @@
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -193,7 +195,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": 0
}
]
},
@@ -225,7 +228,7 @@
"textMode": "auto",
"wideLayout": true
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -292,7 +295,7 @@
"sort": "none"
}
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -321,7 +324,7 @@
"type": "prometheus",
"uid": "$datasource"
},
"description": "SSL certificate expiration times for all services",
"description": "Domain expiration times for all domains",
"fieldConfig": {
"defaults": {
"color": {
@@ -339,7 +342,8 @@
"mode": "absolute",
"steps": [
{
"color": "red"
"color": "red",
"value": 0
},
{
"color": "#EAB839",
@@ -395,7 +399,137 @@
"showHeader": true,
"sortBy": []
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"editorMode": "code",
"expr": "gatus_results_domain_expiration_seconds",
"format": "table",
"instant": true,
"legendFormat": "__auto",
"refId": "A"
}
],
"title": "Domain Expiration",
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true,
"Value": false,
"__name__": true,
"app_kubernetes_io_instance": true,
"app_kubernetes_io_managed_by": true,
"app_kubernetes_io_name": true,
"app_kubernetes_io_service": true,
"helm_sh_chart": true,
"instance": true,
"job": true,
"key": true,
"type": true
},
"includeByName": {},
"indexByName": {
"Value": 2,
"group": 0,
"name": 1
},
"renameByName": {
"Value": "Time Until Expiry",
"group": "Group",
"name": "Service"
}
}
}
],
"type": "table"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"description": "SSL certificate expiration times for all services",
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": 0
},
{
"color": "#EAB839",
"value": 172800
},
{
"color": "green",
"value": 604800
}
]
},
"unit": "dtdurations"
},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "Time Until Expiry"
},
"properties": [
{
"id": "custom.cellOptions",
"value": {
"applyToRow": false,
"type": "color-background"
}
},
{
"id": "unit",
"value": "s"
}
]
}
]
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 8
},
"id": 11,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": [
"sum"
],
"show": false
},
"showHeader": true,
"sortBy": []
},
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -444,6 +578,113 @@
],
"type": "table"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"description": "Current status distribution across all services",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 16
},
"id": 5,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"expr": "sum(gatus_results_endpoint_success)",
"legendFormat": "Services UP",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"expr": "sum(1 - gatus_results_endpoint_success)",
"legendFormat": "Services DOWN",
"refId": "B"
}
],
"title": "Service Status Distribution",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
@@ -483,7 +724,8 @@
"mode": "absolute",
"steps": [
{
"color": "red"
"color": "red",
"value": 0
},
{
"color": "green",
@@ -525,7 +767,7 @@
"h": 8,
"w": 12,
"x": 12,
"y": 8
"y": 16
},
"id": 2,
"options": {
@@ -546,7 +788,7 @@
}
]
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -689,7 +931,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": 0
},
{
"color": "red",
@@ -703,9 +946,9 @@
},
"gridPos": {
"h": 8,
"w": 12,
"w": 24,
"x": 0,
"y": 16
"y": 24
},
"id": 4,
"options": {
@@ -721,7 +964,7 @@
"sort": "none"
}
},
"pluginVersion": "12.0.2",
"pluginVersion": "12.1.0",
"targets": [
{
"datasource": {
@@ -735,112 +978,6 @@
],
"title": "Response Times",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"description": "Current status distribution across all services",
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"vis": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green"
},
{
"color": "red",
"value": 80
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 16
},
"id": 5,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.0.2",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"expr": "sum(gatus_results_endpoint_success)",
"legendFormat": "Services UP",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "$datasource"
},
"expr": "sum(1 - gatus_results_endpoint_success)",
"legendFormat": "Services DOWN",
"refId": "B"
}
],
"title": "Service Status Distribution",
"type": "timeseries"
}
],
"preload": false,
@@ -855,8 +992,8 @@
"list": [
{
"current": {
"text": "myprom",
"value": "PA04845DA3A4B088E"
"text": "prometheus",
"value": "cedv077q7bbwgd"
},
"description": "Select your Prometheus datasource",
"includeAll": false,
@@ -877,6 +1014,6 @@
"timepicker": {},
"timezone": "",
"title": "Gatus - Service Monitoring Dashboard",
"uid": "gatus-monitoring2",
"version": 10
}
"uid": "4ea25b6f-2edc-416c-8282-a1164f95537a",
"version": 1
}
@@ -1,4 +1,3 @@
version: "3.9"
services:
gatus:
container_name: gatus
@@ -1,4 +1,3 @@
version: "3.9"
services:
nginx:
image: nginx:stable
@@ -1,4 +1,3 @@
version: "3.8"
services:
gatus:
image: twinproduction/gatus:latest
@@ -1,9 +1,10 @@
version: "3.9"
services:
postgres:
image: postgres
volumes:
- ./data/db:/var/lib/postgresql/data
# PostgreSQL 18 and above declare the volume at /var/lib/postgresql.
# For 17 and earlier, mount ./data/db at /var/lib/postgresql/data instead.
- ./data/db:/var/lib/postgresql
ports:
- "5432:5432"
environment:
@@ -1,4 +1,3 @@
version: "3.9"
services:
gatus:
image: twinproduction/gatus:latest
@@ -1,4 +1,3 @@
version: "3.8"
services:
gatus:
image: twinproduction/gatus:latest
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 89.76 89.75"><defs><style>.cls-1{fill:#3cad4b;}.cls-2{fill:#017400;}.cls-3{fill:#1e9025;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M33.67,65.35a23.35,23.35,0,0,1,.08-41,22.94,22.94,0,0,1,3.8-1.64A23,23,0,0,0,53.6,1C53,0,51,0,44.89,0c-9.08,0-9.21.17-8.81,3.22,1.07,8.12-9.42,12.5-14.45,6-1.94-2.52-2.1-2.52-8.68,4.16-6.22,6.3-6.33,6.28-3.77,8.25a8.09,8.09,0,0,1,2.56,9.53A8.15,8.15,0,0,1,3.08,36C0,35.63,0,35.73,0,45.2.08,53.81,0,54,3.3,53.63A8.06,8.06,0,0,1,9.76,67.52c-3,2.83-2.84,2.61,2.84,8.48,5.43,5.62,6.33,6.73,8.16,5.24L34,68A1.63,1.63,0,0,0,33.67,65.35Z"/><path class="cls-2" d="M85.43,36.13a8.11,8.11,0,0,1-5.27-14.21c2.85-2.5,2.82-2.37-3.55-8.75-4.31-4.31-5.71-5.75-6.87-5.4l-14,14a1.65,1.65,0,0,0,.36,2.61,23.35,23.35,0,0,1-.1,41,24.5,24.5,0,0,1-5.11,2c-8.54,2.28-14.73,9.63-14.73,18.47v1.27c.15,2.54,1.19,2.42,8.06,2.52,9.32.14,9.1.35,9.38-4.66a8.11,8.11,0,0,1,14-5.09c3,3.15,2.39,3.11,8.73-3.14,6.56-6.47,6.86-6.25,3.68-9.14a8.1,8.1,0,0,1,6.06-14.07c3.68.27,3.51.06,3.63-8.09C89.85,36.27,90,36.16,85.43,36.13Z"/><path class="cls-3" d="M41.11,59h8a.76.76,0,0,0,.77-.76V50.43a.76.76,0,0,1,.77-.76h7.84a.78.78,0,0,0,.77-.77V40.84a.77.77,0,0,0-.77-.76H50.7a.76.76,0,0,1-.77-.77V31.47a.76.76,0,0,0-.77-.77h-8a.76.76,0,0,0-.77.77v7.84a.76.76,0,0,1-.77.77H31.73a.77.77,0,0,0-.77.76V48.9a.78.78,0,0,0,.77.77h7.84a.76.76,0,0,1,.77.76v7.85A.76.76,0,0,0,41.11,59Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

-15
View File
@@ -1,15 +0,0 @@
ignore:
- "storage/store/sql/specific_postgres.go" # Can't test for postgres
- "watchdog/endpoint.go"
- "watchdog/external_endpoint.go"
- "watchdog/suite.go"
- "watchdog/watchdog.go"
comment: false
coverage:
status:
patch: off
project:
default:
target: 70%
threshold: null
+1
View File
@@ -0,0 +1 @@
See AGENTS.md for agent instructions
+2 -2
View File
@@ -22,9 +22,9 @@ jobs:
steps:
- uses: actions/setup-go@v6
with:
go-version: 1.24.4
go-version: 1.26.3
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
ref: "${{ github.event.inputs.ref || 'master' }}"
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Benchmark
run: go test -bench=. ./storage/store
+16 -7
View File
@@ -5,35 +5,44 @@ on:
inputs:
tag:
description: Custom tag to publish
platforms:
description: Platforms to publish to (comma separated list)
default: linux/amd64
type: choice
options:
- linux/amd64
- linux/arm/v7
- linux/arm64
jobs:
publish-custom:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Get image repository
run: echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ${{ env.GHCR_IMAGE_REPOSITORY }}
tags: |
type=raw,value=${{ inputs.tag }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
platforms: linux/amd64
platforms: ${{ inputs.platforms }}
pull: true
push: true
tags: ${{ steps.meta.outputs.tags }}
+6 -6
View File
@@ -5,27 +5,27 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Login to Docker Registry
uses: docker/login-action@v3
uses: docker/login-action@v4.6.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ${{ env.IMAGE_REPOSITORY }}
tags: |
type=raw,value=experimental
- name: Build and push Docker image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
platforms: linux/amd64
pull: true
+7 -7
View File
@@ -13,29 +13,29 @@ jobs:
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
timeout-minutes: 240
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Get image repository
run: |
echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Login to Docker Registry
uses: docker/login-action@v3
uses: docker/login-action@v4.6.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.DOCKER_IMAGE_REPOSITORY }}
@@ -43,7 +43,7 @@ jobs:
tags: |
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
platforms: linux/amd64,linux/arm/v7,linux/arm64
pull: true
+7 -7
View File
@@ -8,11 +8,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 240
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Get image repository
run: |
echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
@@ -20,19 +20,19 @@ jobs:
- name: Get the release
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
- name: Login to Docker Registry
uses: docker/login-action@v3
uses: docker/login-action@v4.6.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.DOCKER_IMAGE_REPOSITORY }}
@@ -42,7 +42,7 @@ jobs:
type=raw,value=stable
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
platforms: linux/amd64,linux/arm/v7,linux/arm64
pull: true
@@ -0,0 +1,110 @@
name: regenerate-static-assets
on:
issue_comment:
types: [created]
jobs:
check-command:
runs-on: ubuntu-latest
if: ${{ github.event.issue.pull_request }}
permissions:
pull-requests: write # required for adding reactions to command comments on PRs
checks: read # required to check if all ci checks have passed
outputs:
continue: ${{ steps.command.outputs.continue }}
steps:
- name: Check command trigger
id: command
uses: github/command@v2
with:
command: "/regenerate-static-assets"
permissions: "write,admin" # The allowed permission levels to invoke this command
allow_forks: true
allow_drafts: true
skip_ci: true
skip_completing: true
regenerate-static-assets:
runs-on: ubuntu-latest
needs: check-command
if: ${{ needs.check-command.outputs.continue == 'true' }}
permissions:
contents: write
outputs:
status: ${{ steps.commit.outputs.status }}
steps:
- name: Get PR branch
id: pr
uses: actions/github-script@v9
with:
script: |
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number
});
core.setOutput('ref', pr.data.head.ref);
core.setOutput('repo', pr.data.head.repo.full_name);
- name: Checkout PR branch
uses: actions/checkout@v7
with:
repository: ${{ steps.pr.outputs.repo }}
ref: ${{ steps.pr.outputs.ref }}
- name: Regenerate static assets
run: |
make frontend-install
make frontend-build
- name: Commit and push changes
id: commit
run: |
echo "Checking for changes..."
if git diff --quiet; then
echo "No changes detected."
echo "status=no_changes" >> $GITHUB_OUTPUT
exit 0
fi
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
echo "Changes detected. Committing and pushing..."
git add .
git commit -m "chore(ui): Regenerate static assets"
git push origin ${{ steps.pr.outputs.ref }}
echo "status=success" >> $GITHUB_OUTPUT
create-response-comment:
runs-on: ubuntu-latest
needs: [check-command, regenerate-static-assets]
if: ${{ !cancelled() && needs.check-command.outputs.continue == 'true' }}
permissions:
pull-requests: write
steps:
- name: Create response comment
uses: actions/github-script@v9
with:
script: |
const status = '${{ needs.regenerate-static-assets.outputs.status }}';
let reaction = 'hooray';
let message = '';
var workflowUrl = `${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}`;
if (status === 'no_changes') {
message = `@${context.actor} No changes to commit ([ref](${workflowUrl})).`;
} else if (status === 'success') {
// Assets regenerated successfully - no comment needed, just add reaction
} else {
reaction = '-1';
message = `@${context.actor} There was an issue regenerating static assets. Please check the [workflow run logs](${workflowUrl}) for more details.`;
}
if (message.length) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: message
});
}
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: reaction
});
+2 -2
View File
@@ -13,6 +13,6 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- run: make frontend-install-dependencies
- uses: actions/checkout@v7
- run: make frontend-install
- run: make frontend-build
+3 -8
View File
@@ -18,17 +18,12 @@ jobs:
steps:
- uses: actions/setup-go@v6
with:
go-version: 1.24.4
- uses: actions/checkout@v5
go-version: 1.26.3
- uses: actions/checkout@v7
- name: Build binary to make sure it works
run: go build
- name: Test
# We're using "sudo" because one of the tests leverages ping, which requires super-user privileges.
# As for the 'env "PATH=$PATH" "GOROOT=$GOROOT"', we need it to use the same "go" executable that
# was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable)
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race -coverprofile=coverage.txt -covermode=atomic
- name: Codecov
uses: codecov/codecov-action@v5.5.1
with:
files: ./coverage.txt
token: ${{ secrets.CODECOV_TOKEN }}
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race
+84
View File
@@ -0,0 +1,84 @@
# AGENTS.md
Guidance for AI coding agents working in this repository.
## Commands
- `make install` — Build the `gatus` binary
- `make run` — Run in dev mode (uses `./config.yaml`)
- `make test` — Run all Go tests with coverage
- `make clean` — Remove built binary
- `make frontend-install` — Install frontend npm dependencies
- `make frontend-build` — Build the Vue.js frontend (**must run after any `web/` changes**)
- `make frontend-dev` — Start the Vue dev server
## Key Rules
- **Vendored dependencies**: After adding/updating a Go dependency, run `go mod tidy && go mod vendor`
- **Frontend build artifacts**: `web/static/` is generated by `make frontend-build` and embedded into the Go binary at compile time via `//go:embed`. Never edit files in `web/static/` directly
- **Config validation order**: In `config/config.go` `parseAndValidateConfigBytes`, `ValidateAlertingConfig` **must** run before `ValidateEndpointsConfig` because provider default alerts must be parsed before endpoint defaults are set
- **Invalid configs panic at startup** — validation errors are fatal, not graceful
- **Hot-reload**: `main.go` watches the config file and re-runs the full stop → save → load → init → start cycle. New startup logic must account for this
## Environment Variables
- `GATUS_CONFIG_PATH` — Config file path (default: `config/config.yaml`); can be a directory of YAML files (deep-merged)
- `GATUS_LOG_LEVEL``DEBUG`, `INFO`, `WARN`, `ERROR`
- `ENVIRONMENT=dev` — Enables CORS for frontend dev on `localhost:8081`
## Project Structure
- `main.go` — Entry point; loads config, initializes storage, starts watchdog + API server
- `config/` — Central config struct (`config.go`) and sub-packages per config section (each with `ValidateAndSetDefaults()`)
- `alerting/` — Alerting config (`config.go`), alert types (`alert/type.go`), and one sub-package per provider under `provider/`
- `api/` — Fiber-based HTTP API routes and SPA serving
- `watchdog/` — Monitoring loop; spawns a goroutine per endpoint with semaphore-based concurrency control
- `client/` — HTTP/gRPC client used for health checks
- `storage/store/` — Storage abstraction with `memory/`, `sql/` (SQLite + PostgreSQL) implementations
- `security/` — Authentication (basic + OIDC) and security middleware
- `web/app/` — Vue 3 frontend source; builds to `web/static/` (embedded into the Go binary)
- `vendor/` — Vendored Go dependencies (committed to repo)
## Common Changes
### Adding an Alerting Provider
Use `alerting/provider/slack/` as the reference implementation. Every new provider touches **6 locations**:
1. **Create provider package**`alerting/provider/{name}/{name}.go` + `{name}_test.go`
- `Config` struct with `Validate()` and `Merge(*Config)` methods
- `AlertProvider` struct with `DefaultConfig` (yaml inline), `DefaultAlert *alert.Alert`, `Overrides []Override`
- Use `client.GetHTTPClient(nil)` (not `http.DefaultClient`), always `defer response.Body.Close()`
2. **Register type** — Add `Type{Name} Type = "{name}"` in `alerting/alert/type.go`
3. **Add to config struct** — Add field to `alerting.Config` in `alerting/config.go`; the **YAML tag must exactly match** the `alert.Type` string (reflection-based lookup via `GetAlertingProviderByAlertType`)
4. **Add compile-time checks** — Both `_ AlertProvider = (*pkg.AlertProvider)(nil)` **and** `_ Config[pkg.Config] = (*pkg.Config)(nil)` in `alerting/provider/provider.go`
5. **Register in validation** — Add the type to the `alertTypes` slice in `config/config.go` `ValidateAlertingConfig`
6. **Document** — Add to README.md in alphabetical order
### Adding a Config Section
1. Create package in `config/` with struct + `ValidateAndSetDefaults()` method
2. Add field to `Config` struct in `config/config.go`
3. Add `Validate*` call in `parseAndValidateConfigBytes`**respect ordering constraints** (see comments in that function)
## Frontend
- Vue 3 Composition API (`<script setup>`) + Tailwind CSS
- Props-based data flow — **do not use provide/inject**
- All components must include `dark:` prefixed Tailwind classes for dark mode
- `window.config` contains server-injected UI settings (Go template placeholders)
## API Routes (`api/api.go`)
- Unprotected routes are registered **before** `ApplySecurityMiddleware`
- Protected routes are registered **after** — placement order matters
## Testing
- Config structs should have `*_test.go` files
- Use `t.Parallel()` where appropriate
- API tests spin up test servers (see existing `*_test.go` in `api/`)
## Commits & PRs
When creating a commit or PR as an agent, state that it was made by an agent and include your model name and version.
+1 -1
View File
@@ -3,7 +3,7 @@ FROM golang:alpine AS builder
RUN apk --update add ca-certificates
WORKDIR /app
COPY . ./
RUN go mod tidy
RUN go mod tidy -diff
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
+8 -2
View File
@@ -25,12 +25,15 @@ test:
# Docker #
##########
.PHONY: docker-build
docker-build:
docker build -t twinproduction/gatus:latest .
.PHONY: docker-run
docker-run:
docker run -p 8080:8080 --name gatus twinproduction/gatus:latest
.PHONY: docker-build-and-run
docker-build-and-run: docker-build docker-run
@@ -38,11 +41,14 @@ docker-build-and-run: docker-build docker-run
# Front end #
#############
frontend-install-dependencies:
.PHONY: frontend-install
frontend-install:
npm --prefix web/app install
.PHONY: frontend-build
frontend-build:
npm --prefix web/app run build
frontend-run:
.PHONY: frontend-dev
frontend-dev:
npm --prefix web/app run serve
+528 -231
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -15,6 +15,8 @@ import (
var (
// ErrAlertWithInvalidDescription is the error with which Gatus will panic if an alert has an invalid character
ErrAlertWithInvalidDescription = errors.New("alert description must not have \" or \\")
ErrAlertWithInvalidMinimumReminderInterval = errors.New("minimum-reminder-interval must be either omitted or be at least 5m")
)
// Alert is endpoint.Endpoint's alert configuration
@@ -78,6 +80,9 @@ func (alert *Alert) ValidateAndSetDefaults() error {
if alert.SuccessThreshold <= 0 {
alert.SuccessThreshold = 2
}
if alert.MinimumReminderInterval != 0 && alert.MinimumReminderInterval < 5*time.Minute {
return ErrAlertWithInvalidMinimumReminderInterval
}
if strings.ContainsAny(alert.GetDescription(), "\"\\") {
return ErrAlertWithInvalidDescription
}
+56
View File
@@ -3,6 +3,7 @@ package alert
import (
"errors"
"testing"
"time"
)
func TestAlert_ValidateAndSetDefaults(t *testing.T) {
@@ -36,6 +37,61 @@ func TestAlert_ValidateAndSetDefaults(t *testing.T) {
expectedFailureThreshold: 10,
expectedSuccessThreshold: 5,
},
{
name: "valid-minimum-reminder-interval-0",
alert: Alert{
MinimumReminderInterval: 0,
FailureThreshold: 10,
SuccessThreshold: 5,
},
expectedError: nil,
expectedFailureThreshold: 10,
expectedSuccessThreshold: 5,
},
{
name: "valid-minimum-reminder-interval-5m",
alert: Alert{
MinimumReminderInterval: 5 * time.Minute,
FailureThreshold: 10,
SuccessThreshold: 5,
},
expectedError: nil,
expectedFailureThreshold: 10,
expectedSuccessThreshold: 5,
},
{
name: "valid-minimum-reminder-interval-10m",
alert: Alert{
MinimumReminderInterval: 10 * time.Minute,
FailureThreshold: 10,
SuccessThreshold: 5,
},
expectedError: nil,
expectedFailureThreshold: 10,
expectedSuccessThreshold: 5,
},
{
name: "invalid-minimum-reminder-interval-1m",
alert: Alert{
MinimumReminderInterval: 1 * time.Minute,
FailureThreshold: 10,
SuccessThreshold: 5,
},
expectedError: ErrAlertWithInvalidMinimumReminderInterval,
expectedFailureThreshold: 10,
expectedSuccessThreshold: 5,
},
{
name: "invalid-minimum-reminder-interval-1s",
alert: Alert{
MinimumReminderInterval: 1 * time.Second,
FailureThreshold: 10,
SuccessThreshold: 5,
},
expectedError: ErrAlertWithInvalidMinimumReminderInterval,
expectedFailureThreshold: 10,
expectedSuccessThreshold: 5,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
+6 -3
View File
@@ -8,6 +8,9 @@ const (
// TypeAWSSES is the Type for the awsses alerting provider
TypeAWSSES Type = "aws-ses"
// TypeClickUp is the Type for the clickup alerting provider
TypeClickUp Type = "clickup"
// TypeCustom is the Type for the custom alerting provider
TypeCustom Type = "custom"
@@ -47,9 +50,6 @@ const (
// TypeIncidentIO is the Type for the incident-io alerting provider
TypeIncidentIO Type = "incident-io"
// TypeJetBrainsSpace is the Type for the jetbrains alerting provider
TypeJetBrainsSpace Type = "jetbrainsspace"
// TypeLine is the Type for the line alerting provider
TypeLine Type = "line"
@@ -65,6 +65,9 @@ const (
// TypeNewRelic is the Type for the newrelic alerting provider
TypeNewRelic Type = "newrelic"
// TypeN8N is the Type for the n8n alerting provider
TypeN8N Type = "n8n"
// TypeNtfy is the Type for the ntfy alerting provider
TypeNtfy Type = "ntfy"
+10 -7
View File
@@ -7,6 +7,7 @@ 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/clickup"
"github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
"github.com/TwiN/gatus/v5/alerting/provider/discord"
@@ -20,11 +21,11 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
"github.com/TwiN/gatus/v5/alerting/provider/line"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
"github.com/TwiN/gatus/v5/alerting/provider/n8n"
"github.com/TwiN/gatus/v5/alerting/provider/newrelic"
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
@@ -54,6 +55,9 @@ type Config struct {
// AWSSimpleEmailService is the configuration for the aws-ses alerting provider
AWSSimpleEmailService *awsses.AlertProvider `yaml:"aws-ses,omitempty"`
// ClickUp is the configuration for the clickup alerting provider
ClickUp *clickup.AlertProvider `yaml:"clickup,omitempty"`
// Custom is the configuration for the custom alerting provider
Custom *custom.AlertProvider `yaml:"custom,omitempty"`
@@ -66,7 +70,6 @@ type Config struct {
// Email is the configuration for the email alerting provider
Email *email.AlertProvider `yaml:"email,omitempty"`
// GitHub is the configuration for the github alerting provider
GitHub *github.AlertProvider `yaml:"github,omitempty"`
@@ -81,22 +84,19 @@ type Config struct {
// Gotify is the configuration for the gotify alerting provider
Gotify *gotify.AlertProvider `yaml:"gotify,omitempty"`
// HomeAssistant is the configuration for the homeassistant alerting provider
HomeAssistant *homeassistant.AlertProvider `yaml:"homeassistant,omitempty"`
// IFTTT is the configuration for the ifttt alerting provider
IFTTT *ifttt.AlertProvider `yaml:"ifttt,omitempty"`
// Ilert is the configuration for the ilert alerting provider
Ilert *ilert.AlertProvider `yaml:"ilert,omitempty"`
// IncidentIO is the configuration for the incident-io alerting provider
IncidentIO *incidentio.AlertProvider `yaml:"incident-io,omitempty"`
// JetBrainsSpace is the configuration for the jetbrains space alerting provider
JetBrainsSpace *jetbrainsspace.AlertProvider `yaml:"jetbrainsspace,omitempty"`
// Line is the configuration for the line alerting provider
Line *line.AlertProvider `yaml:"line,omitempty"`
@@ -112,6 +112,9 @@ type Config struct {
// NewRelic is the configuration for the newrelic alerting provider
NewRelic *newrelic.AlertProvider `yaml:"newrelic,omitempty"`
// N8N is the configuration for the n8n alerting provider
N8N *n8n.AlertProvider `yaml:"n8n,omitempty"`
// Ntfy is the configuration for the ntfy alerting provider
Ntfy *ntfy.AlertProvider `yaml:"ntfy,omitempty"`
+25 -38
View File
@@ -1,18 +1,18 @@
package awsses
import (
"context"
"errors"
"fmt"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/logr"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ses"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/ses"
"github.com/aws/aws-sdk-go-v2/service/ses/types"
"gopkg.in/yaml.v3"
)
@@ -102,63 +102,50 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
if err != nil {
return err
}
awsSession, err := provider.createSession(cfg)
ctx := context.Background()
svc, err := provider.createClient(ctx, cfg)
if err != nil {
return err
}
svc := ses.New(awsSession)
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
emails := strings.Split(cfg.To, ",")
input := &ses.SendEmailInput{
Destination: &ses.Destination{
ToAddresses: aws.StringSlice(emails),
Destination: &types.Destination{
ToAddresses: emails,
},
Message: &ses.Message{
Body: &ses.Body{
Text: &ses.Content{
Message: &types.Message{
Body: &types.Body{
Text: &types.Content{
Charset: aws.String(CharSet),
Data: aws.String(body),
},
},
Subject: &ses.Content{
Subject: &types.Content{
Charset: aws.String(CharSet),
Data: aws.String(subject),
},
},
Source: aws.String(cfg.From),
}
if _, err = svc.SendEmail(input); err != nil {
if aerr, ok := err.(awserr.Error); ok {
switch aerr.Code() {
case ses.ErrCodeMessageRejected:
logr.Error(ses.ErrCodeMessageRejected + ": " + aerr.Error())
case ses.ErrCodeMailFromDomainNotVerifiedException:
logr.Error(ses.ErrCodeMailFromDomainNotVerifiedException + ": " + aerr.Error())
case ses.ErrCodeConfigurationSetDoesNotExistException:
logr.Error(ses.ErrCodeConfigurationSetDoesNotExistException + ": " + aerr.Error())
default:
logr.Error(aerr.Error())
}
} else {
// Print the error, cast err to awserr.Error to get the Code and
// Message from an error.
logr.Error(err.Error())
}
if _, err = svc.SendEmail(ctx, input); err != nil {
return err
}
return nil
}
func (provider *AlertProvider) createSession(cfg *Config) (*session.Session, error) {
awsConfig := &aws.Config{
Region: aws.String(cfg.Region),
func (provider *AlertProvider) createClient(ctx context.Context, cfg *Config) (*ses.Client, error) {
var opts []func(*config.LoadOptions) error
if len(cfg.Region) > 0 {
opts = append(opts, config.WithRegion(cfg.Region))
}
if len(cfg.AccessKeyID) > 0 && len(cfg.SecretAccessKey) > 0 {
awsConfig.Credentials = credentials.NewStaticCredentials(cfg.AccessKeyID, cfg.SecretAccessKey, "")
opts = append(opts, config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, "")))
}
return session.NewSession(awsConfig)
awsConfig, err := config.LoadDefaultConfig(ctx, opts...)
if err != nil {
return nil, err
}
return ses.NewFromConfig(awsConfig), nil
}
// buildMessageSubjectAndBody builds the message subject and body
+285
View File
@@ -0,0 +1,285 @@
package clickup
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
)
var (
ErrListIDNotSet = errors.New("list-id not set")
ErrTokenNotSet = errors.New("token not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
ErrInvalidPriority = errors.New("priority must be one of: urgent, high, normal, low, none")
)
var priorityMap = map[string]int{
"urgent": 1,
"high": 2,
"normal": 3,
"low": 4,
"none": 0,
}
type Config struct {
APIURL string `yaml:"api-url"`
ListID string `yaml:"list-id"`
Token string `yaml:"token"`
Assignees []string `yaml:"assignees"`
Status string `yaml:"status"`
Priority string `yaml:"priority"`
NotifyAll *bool `yaml:"notify-all,omitempty"`
Name string `yaml:"name,omitempty"`
MarkdownContent string `yaml:"content,omitempty"`
}
func (cfg *Config) Validate() error {
if cfg.ListID == "" {
return ErrListIDNotSet
}
if cfg.Token == "" {
return ErrTokenNotSet
}
if cfg.Priority == "" {
cfg.Priority = "normal"
}
if _, ok := priorityMap[cfg.Priority]; !ok {
return ErrInvalidPriority
}
if cfg.NotifyAll == nil {
defaultNotifyAll := true
cfg.NotifyAll = &defaultNotifyAll
}
if cfg.APIURL == "" {
cfg.APIURL = "https://api.clickup.com/api/v2"
}
if cfg.Name == "" {
cfg.Name = "Health Check: [ENDPOINT_GROUP]:[ENDPOINT_NAME]"
}
if cfg.MarkdownContent == "" {
cfg.MarkdownContent = "Triggered: [ENDPOINT_GROUP] - [ENDPOINT_NAME] - [ALERT_DESCRIPTION] - [RESULT_ERRORS]"
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.APIURL != "" {
cfg.APIURL = override.APIURL
}
if override.ListID != "" {
cfg.ListID = override.ListID
}
if override.Token != "" {
cfg.Token = override.Token
}
if override.Status != "" {
cfg.Status = override.Status
}
if override.Priority != "" {
cfg.Priority = override.Priority
}
if override.NotifyAll != nil {
cfg.NotifyAll = override.NotifyAll
}
if len(override.Assignees) > 0 {
cfg.Assignees = override.Assignees
}
if override.Name != "" {
cfg.Name = override.Name
}
if override.MarkdownContent != "" {
cfg.MarkdownContent = override.MarkdownContent
}
}
// AlertProvider is the configuration necessary for sending an alert using ClickUp
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
// Override is a case under which the default configuration is overridden
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
}
return provider.DefaultConfig.Validate()
}
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
if resolved {
return provider.CloseTask(cfg, ep)
}
// Replace placeholders
name := strings.ReplaceAll(cfg.Name, "[ENDPOINT_GROUP]", ep.Group)
name = strings.ReplaceAll(name, "[ENDPOINT_NAME]", ep.Name)
markdownContent := strings.ReplaceAll(cfg.MarkdownContent, "[ENDPOINT_GROUP]", ep.Group)
markdownContent = strings.ReplaceAll(markdownContent, "[ENDPOINT_NAME]", ep.Name)
markdownContent = strings.ReplaceAll(markdownContent, "[ALERT_DESCRIPTION]", alert.GetDescription())
markdownContent = strings.ReplaceAll(markdownContent, "[RESULT_ERRORS]", strings.Join(result.Errors, ", "))
body := map[string]interface{}{
"name": name,
"markdown_content": markdownContent,
"assignees": cfg.Assignees,
"status": cfg.Status,
"notify_all": *cfg.NotifyAll,
}
if cfg.Priority != "none" {
body["priority"] = priorityMap[cfg.Priority]
}
return provider.CreateTask(cfg, body)
}
func (provider *AlertProvider) CreateTask(cfg *Config, body map[string]interface{}) error {
jsonBody, err := json.Marshal(body)
if err != nil {
return err
}
createURL := fmt.Sprintf("%s/list/%s/task", cfg.APIURL, cfg.ListID)
req, err := http.NewRequest("POST", createURL, bytes.NewBuffer(jsonBody))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", cfg.Token)
httpClient := client.GetHTTPClient(nil)
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("failed to create task, status: %d", resp.StatusCode)
}
return nil
}
func (provider *AlertProvider) CloseTask(cfg *Config, ep *endpoint.Endpoint) error {
fetchURL := fmt.Sprintf("%s/list/%s/task?include_closed=false", cfg.APIURL, cfg.ListID)
req, err := http.NewRequest("GET", fetchURL, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", cfg.Token)
httpClient := client.GetHTTPClient(nil)
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("failed to fetch tasks, status: %d", resp.StatusCode)
}
var fetchResponse struct {
Tasks []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"tasks"`
}
if err := json.NewDecoder(resp.Body).Decode(&fetchResponse); err != nil {
return err
}
var matchingTaskIDs []string
for _, task := range fetchResponse.Tasks {
if strings.Contains(task.Name, ep.Group) && strings.Contains(task.Name, ep.Name) {
matchingTaskIDs = append(matchingTaskIDs, task.ID)
}
}
if len(matchingTaskIDs) == 0 {
return fmt.Errorf("no matching tasks found for %s:%s", ep.Group, ep.Name)
}
for _, taskID := range matchingTaskIDs {
if err := provider.UpdateTaskStatus(cfg, taskID, "closed"); err != nil {
return fmt.Errorf("failed to close task %s: %v", taskID, err)
}
}
return nil
}
func (provider *AlertProvider) UpdateTaskStatus(cfg *Config, taskID, status string) error {
updateURL := fmt.Sprintf("%s/task/%s", cfg.APIURL, taskID)
body := map[string]interface{}{"status": status}
jsonBody, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequest("PUT", updateURL, bytes.NewBuffer(jsonBody))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", cfg.Token)
httpClient := client.GetHTTPClient(nil)
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("failed to update task %s, status: %d", taskID, resp.StatusCode)
}
return nil
}
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
+310
View File
@@ -0,0 +1,310 @@
package clickup
import (
"bytes"
"encoding/json"
"io"
"net/http"
"testing"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/test"
)
func TestAlertProvider_Validate(t *testing.T) {
invalidProviderNoListID := AlertProvider{DefaultConfig: Config{ListID: "", Token: "test-token"}}
if err := invalidProviderNoListID.Validate(); err == nil {
t.Error("provider shouldn't have been valid without list-id")
}
invalidProviderNoToken := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: ""}}
if err := invalidProviderNoToken.Validate(); err == nil {
t.Error("provider shouldn't have been valid without token")
}
invalidProviderBadPriority := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "invalid"}}
if err := invalidProviderBadPriority.Validate(); err == nil {
t.Error("provider shouldn't have been valid with invalid priority")
}
validProvider := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
if validProvider.DefaultConfig.Priority != "normal" {
t.Errorf("expected default priority to be 'normal', got '%s'", validProvider.DefaultConfig.Priority)
}
validProviderWithAPIURL := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", APIURL: "https://api.clickup.com/api/v2"}}
if err := validProviderWithAPIURL.Validate(); err != nil {
t.Error("provider should've been valid")
}
validProviderWithPriority := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "urgent"}}
if err := validProviderWithPriority.Validate(); err != nil {
t.Error("provider should've been valid with priority 'urgent'")
}
validProviderWithNone := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Priority: "none"}}
if err := validProviderWithNone.Validate(); err != nil {
t.Error("provider should've been valid with priority 'none'")
}
}
func TestAlertProvider_ValidateSetsDefaultAPIURL(t *testing.T) {
provider := AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}}
if err := provider.Validate(); err != nil {
t.Error("provider should've been valid")
}
if provider.DefaultConfig.APIURL != "https://api.clickup.com/api/v2" {
t.Errorf("expected APIURL to be set to default, got %s", provider.DefaultConfig.APIURL)
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
scenarios := []struct {
Name string
Provider AlertProvider
Endpoint endpoint.Endpoint
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.Method == "POST" && r.URL.Path == "/api/v2/list/test-list-id/task" {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.Method == "GET" {
// Mock fetch tasks response
tasksResponse := map[string]interface{}{
"tasks": []map[string]interface{}{
{
"id": "task-123",
"name": "Health Check: endpoint-group:endpoint-name",
},
},
}
body, _ := json.Marshal(tasksResponse)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
}
}
if r.Method == "PUT" {
// Mock update task status response
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-no-matching-tasks",
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
if r.Method == "GET" {
// Mock fetch tasks response with no matching tasks
tasksResponse := map[string]interface{}{
"tasks": []map[string]interface{}{},
}
body, _ := json.Marshal(tasksResponse)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(body)),
}
}
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved-error-fetching-tasks",
Provider: AlertProvider{DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"}},
Endpoint: endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
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(
&scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
Errors: []string{"error1", "error2"},
},
scenario.Resolved,
)
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil {
t.Error("expected no error, got", err.Error())
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("expected default alert to be nil")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "normal"},
},
{
Name: "provider-with-alert-override-should-override",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"list-id": "override-list-id",
"token": "override-token",
}},
ExpectedOutput: Config{ListID: "override-list-id", Token: "override-token", Priority: "normal"},
},
{
Name: "provider-with-partial-alert-override-should-merge",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token", Status: "in progress"},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"status": "closed",
}},
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Status: "closed", Priority: "normal"},
},
{
Name: "provider-with-assignees-override",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"assignees": []string{"user1", "user2"},
}},
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Assignees: []string{"user1", "user2"}, Priority: "normal"},
},
{
Name: "provider-with-priority-override",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"priority": "urgent",
}},
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "urgent"},
},
{
Name: "provider-with-none-priority",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"priority": "none",
}},
ExpectedOutput: Config{ListID: "test-list-id", Token: "test-token", Priority: "none"},
},
{
Name: "provider-with-group-override",
Provider: AlertProvider{
DefaultConfig: Config{ListID: "test-list-id", Token: "test-token"},
Overrides: []Override{
{Group: "core", Config: Config{ListID: "core-list-id", Priority: "urgent"}},
},
},
InputGroup: "core",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ListID: "core-list-id", Token: "test-token", Priority: "urgent"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.ListID != scenario.ExpectedOutput.ListID {
t.Errorf("expected ListID to be %s, got %s", scenario.ExpectedOutput.ListID, got.ListID)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected Token to be %s, got %s", scenario.ExpectedOutput.Token, got.Token)
}
if got.Status != scenario.ExpectedOutput.Status {
t.Errorf("expected Status to be %s, got %s", scenario.ExpectedOutput.Status, got.Status)
}
if got.Priority != scenario.ExpectedOutput.Priority {
t.Errorf("expected Priority to be %s, got %s", scenario.ExpectedOutput.Priority, got.Priority)
}
if len(got.Assignees) != len(scenario.ExpectedOutput.Assignees) {
t.Errorf("expected Assignees length to be %d, got %d", len(scenario.ExpectedOutput.Assignees), len(got.Assignees))
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
+19
View File
@@ -111,6 +111,25 @@ func (provider *AlertProvider) buildHTTPRequest(cfg *Config, ep *endpoint.Endpoi
resultErrors := strings.ReplaceAll(strings.Join(result.Errors, ","), "\"", "\\\"")
body = strings.ReplaceAll(body, "[RESULT_ERRORS]", resultErrors)
url = strings.ReplaceAll(url, "[RESULT_ERRORS]", resultErrors)
if len(result.ConditionResults) > 0 && strings.Contains(body, "[RESULT_CONDITIONS]") {
var formattedConditionResults string
for index, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
formattedConditionResults += fmt.Sprintf("%s - `%s`", prefix, conditionResult.Condition)
if index < len(result.ConditionResults)-1 {
formattedConditionResults += ", "
}
}
body = strings.ReplaceAll(body, "[RESULT_CONDITIONS]", formattedConditionResults)
url = strings.ReplaceAll(url, "[RESULT_CONDITIONS]", formattedConditionResults)
}
if resolved {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, true))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, true))
+63
View File
@@ -261,6 +261,69 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
}
}
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholderAndResultConditions(t *testing.T) {
alertProvider := &AlertProvider{
DefaultConfig: Config{
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED],[RESULT_CONDITIONS]",
Headers: nil,
Placeholders: map[string]map[string]string{
"ALERT_TRIGGERED_OR_RESOLVED": {
"RESOLVED": "fixed",
"TRIGGERED": "boom",
},
},
},
}
alertDescription := "alert-description"
scenarios := []struct {
AlertProvider *AlertProvider
Resolved bool
ExpectedURL string
ExpectedBody string
NoConditions bool
}{
{
AlertProvider: alertProvider,
Resolved: true,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,fixed,✅ - `[CONNECTED] == true`, ✅ - `[STATUS] == 200`",
},
{
AlertProvider: alertProvider,
Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,boom,❌ - `[CONNECTED] == true`, ❌ - `[STATUS] == 200`",
},
}
for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if !scenario.NoConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
}
}
request := alertProvider.buildHTTPRequest(
&alertProvider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&alert.Alert{Description: &alertDescription},
&endpoint.Result{ConditionResults: conditionResults},
scenario.Resolved,
)
if request.URL.String() != scenario.ExpectedURL {
t.Error("expected URL to be", scenario.ExpectedURL, "got", request.URL.String())
}
body, _ := io.ReadAll(request.Body)
if string(body) != scenario.ExpectedBody {
t.Error("expected body to be", scenario.ExpectedBody, "got", string(body))
}
})
}
}
func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
alertProvider := &AlertProvider{
DefaultConfig: Config{
+7 -3
View File
@@ -20,8 +20,9 @@ var (
)
type Config struct {
WebhookURL string `yaml:"webhook-url"`
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
WebhookURL string `yaml:"webhook-url"`
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
MessageContent string `yaml:"message-content,omitempty"` // Message content for pinging users or groups (e.g. "<@123456789>" or "<@&987654321>")
}
func (cfg *Config) Validate() error {
@@ -38,6 +39,9 @@ func (cfg *Config) Merge(override *Config) {
if len(override.Title) > 0 {
cfg.Title = override.Title
}
if len(override.MessageContent) > 0 {
cfg.MessageContent = override.MessageContent
}
}
// AlertProvider is the configuration necessary for sending an alert using Discord
@@ -142,7 +146,7 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
title = cfg.Title
}
body := Body{
Content: "",
Content: cfg.MessageContent,
Embeds: []Embed{
{
Title: title,
+67
View File
@@ -134,6 +134,16 @@ func TestAlertProvider_Send(t *testing.T) {
}),
ExpectedError: false,
},
{
Name: "triggered-with-message-content",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com", MessageContent: "<@123456789>"}},
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) {
@@ -200,6 +210,27 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
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}]}",
},
{
Name: "triggered-with-message-content-user-mention",
Provider: AlertProvider{DefaultConfig: Config{MessageContent: "<@123456789>"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"content\":\"\\u003c@123456789\\u003e\",\"embeds\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
},
{
Name: "triggered-with-message-content-role-mention",
Provider: AlertProvider{DefaultConfig: Config{MessageContent: "<@&987654321>"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"content\":\"\\u003c@\\u0026987654321\\u003e\",\"embeds\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
},
{
Name: "resolved-with-message-content",
Provider: AlertProvider{DefaultConfig: Config{MessageContent: "<@123456789>"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"content\":\"\\u003c@123456789\\u003e\",\"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}]}]}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
@@ -313,6 +344,39 @@ func TestAlertProvider_GetConfig(t *testing.T) {
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
},
{
Name: "provider-with-message-content-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com", MessageContent: "<@123456789>"},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com", MessageContent: "<@123456789>"},
},
{
Name: "provider-with-message-content-group-override",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com", MessageContent: "<@123456789>"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com", MessageContent: "<@&987654321>"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://group-example.com", MessageContent: "<@&987654321>"},
},
{
Name: "provider-with-message-content-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com", MessageContent: "<@123456789>"},
},
InputGroup: "",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"message-content": "<@999999999>"}},
ExpectedOutput: Config{WebhookURL: "http://example.com", MessageContent: "<@999999999>"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
@@ -323,6 +387,9 @@ func TestAlertProvider_GetConfig(t *testing.T) {
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
}
if got.MessageContent != scenario.ExpectedOutput.MessageContent {
t.Errorf("expected message content to be %s, got %s", scenario.ExpectedOutput.MessageContent, got.MessageContent)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
+2 -2
View File
@@ -143,10 +143,10 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
var subject, message string
if resolved {
subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName())
subject = fmt.Sprintf("%s: Alert resolved", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName())
subject = fmt.Sprintf("%s: Alert triggered", ep.DisplayName())
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
var formattedConditionResults string
+5 -5
View File
@@ -86,7 +86,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
ExpectedSubject: "[endpoint-name] Alert triggered",
ExpectedSubject: "endpoint-name: Alert triggered",
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
},
{
@@ -95,7 +95,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
ExpectedSubject: "[endpoint-name] Alert resolved",
ExpectedSubject: "endpoint-name: Alert resolved",
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
},
{
@@ -104,7 +104,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"environment": "production"}},
ExpectedSubject: "[endpoint-name] Alert triggered",
ExpectedSubject: "endpoint-name: Alert triggered",
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nExtra labels:\n environment: production\n\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
},
{
@@ -113,7 +113,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"service": "api"}},
ExpectedSubject: "[endpoint-name] Alert resolved",
ExpectedSubject: "endpoint-name: Alert resolved",
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nExtra labels:\n service: api\n\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
},
{
@@ -122,7 +122,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{}},
ExpectedSubject: "[endpoint-name] Alert triggered",
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",
},
}
+17 -4
View File
@@ -12,6 +12,19 @@ import (
"github.com/TwiN/gatus/v5/test"
)
// isIgnorableTestError checks if an error is expected during testing when making API calls with dummy credentials
func isIgnorableTestError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
return strings.Contains(errStr, "user does not exist") ||
strings.Contains(errStr, "no such host") ||
strings.Contains(errStr, "invalid username, password or token") ||
strings.Contains(errStr, "unknown API error") ||
strings.Contains(errStr, "dial tcp")
}
func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
Name string
@@ -50,7 +63,7 @@ func TestAlertProvider_Validate(t *testing.T) {
if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
if !scenario.ExpectedError && err != nil && !isIgnorableTestError(err) {
t.Error("expected no error, got", err.Error())
}
})
@@ -87,7 +100,7 @@ func TestAlertProvider_Send(t *testing.T) {
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg, err := scenario.Provider.GetConfig("", &scenario.Alert)
if err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
if err != nil && !isIgnorableTestError(err) {
t.Error("expected no error, got", err.Error())
}
cfg.giteaClient, _ = gitea.NewClient("https://gitea.com")
@@ -203,7 +216,7 @@ func TestAlertProvider_GetConfig(t *testing.T) {
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
if err != nil && !isIgnorableTestError(err) {
t.Fatalf("unexpected error: %s", err)
}
if got.RepositoryURL != scenario.ExpectedOutput.RepositoryURL {
@@ -221,7 +234,7 @@ func TestAlertProvider_GetConfig(t *testing.T) {
}
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil && !strings.Contains(err.Error(), "user does not exist") {
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil && !isIgnorableTestError(err) {
t.Errorf("unexpected error: %s", err)
}
})
+18
View File
@@ -0,0 +1,18 @@
package incidentio
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/config/endpoint"
)
// generateDeduplicationKey generates a unique deduplication_key for incident.io
func generateDeduplicationKey(ep *endpoint.Endpoint, alert *alert.Alert) string {
data := fmt.Sprintf("%s|%s|%s|%d", ep.Key(), alert.Type, alert.GetDescription(), time.Now().UnixNano())
hash := sha256.Sum256([]byte(data))
return hex.EncodeToString(hash[:])
}
+21 -5
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"maps"
"net/http"
"strconv"
"strings"
@@ -153,27 +154,42 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
} else {
prefix = "🔴"
}
// No need for \n since incident.io trims it anyways.
formattedConditionResults += fmt.Sprintf(" %s %s ", prefix, conditionResult.Condition)
}
if len(alert.GetDescription()) > 0 {
message += " with the following description: " + alert.GetDescription()
}
message += fmt.Sprintf(" and the following conditions: %s ", formattedConditionResults)
var body []byte
// Generate deduplication key if empty (first firing)
if alert.ResolveKey == "" {
// Generate unique key (endpoint key, alert type, timestamp)
alert.ResolveKey = generateDeduplicationKey(ep, alert)
}
// Extract alert_source_config_id from URL
alertSourceID := strings.TrimPrefix(cfg.URL, restAPIUrl)
body, _ = json.Marshal(Body{
// Merge metadata: cfg.Metadata + ep.ExtraLabels (if present)
mergedMetadata := map[string]interface{}{}
// Copy cfg.Metadata
maps.Copy(mergedMetadata, cfg.Metadata)
// Add extra labels from endpoint (if present)
if ep.ExtraLabels != nil && len(ep.ExtraLabels) > 0 {
for k, v := range ep.ExtraLabels {
mergedMetadata[k] = v
}
}
body, _ := json.Marshal(Body{
AlertSourceConfigID: alertSourceID,
Title: "Gatus: " + ep.DisplayName(),
Status: status,
DeduplicationKey: alert.ResolveKey,
Description: message,
SourceURL: cfg.SourceURL,
Metadata: cfg.Metadata,
Metadata: mergedMetadata,
})
fmt.Printf("%v", string(body))
return body
}
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
+83 -30
View File
@@ -183,39 +183,63 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
secondDescription := "description-2"
restAPIUrl := "https://api.incident.io/v2/alert_events/http/"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedAlertSourceID string
ExpectedStatus string
ExpectedTitle string
ExpectedDescription string
ExpectedSourceURL string
ExpectedMetadata map[string]interface{}
ShouldHaveDeduplicationKey bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"alert_source_config_id":"some-id","status":"firing","title":"Gatus: endpoint-name","description":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 "}`,
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedAlertSourceID: "some-id",
ExpectedStatus: "firing",
ExpectedTitle: "Gatus: endpoint-name",
ExpectedDescription: "An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 ",
ShouldHaveDeduplicationKey: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: `{"alert_source_config_id":"some-id","status":"resolved","title":"Gatus: endpoint-name","description":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 "}`,
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedAlertSourceID: "some-id",
ExpectedStatus: "resolved",
ExpectedTitle: "Gatus: endpoint-name",
ExpectedDescription: "An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 ",
ShouldHaveDeduplicationKey: true,
},
{
Name: "resolved-with-metadata-source-url",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token", Metadata: map[string]interface{}{"service": "some-service", "team": "very-core"}, SourceURL: "some-source-url"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: `{"alert_source_config_id":"some-id","status":"resolved","title":"Gatus: endpoint-name","description":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 ","source_url":"some-source-url","metadata":{"service":"some-service","team":"very-core"}}`,
Name: "resolved-with-metadata-source-url",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token", Metadata: map[string]interface{}{"service": "some-service", "team": "very-core"}, SourceURL: "some-source-url"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedAlertSourceID: "some-id",
ExpectedStatus: "resolved",
ExpectedTitle: "Gatus: endpoint-name",
ExpectedDescription: "An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 ",
ExpectedSourceURL: "some-source-url",
ExpectedMetadata: map[string]interface{}{"service": "some-service", "team": "very-core"},
ShouldHaveDeduplicationKey: true,
},
{
Name: "group-override",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}, Overrides: []Override{{Group: "g", Config: Config{URL: restAPIUrl + "different-id", AuthToken: "some-token"}}}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"alert_source_config_id":"different-id","status":"firing","title":"Gatus: endpoint-name","description":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 "}`,
Name: "group-override",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}, Overrides: []Override{{Group: "g", Config: Config{URL: restAPIUrl + "different-id", AuthToken: "some-token"}}}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedAlertSourceID: "different-id",
ExpectedStatus: "firing",
ExpectedTitle: "Gatus: endpoint-name",
ExpectedDescription: "An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 ",
ShouldHaveDeduplicationKey: true,
},
}
@@ -237,13 +261,42 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
},
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 {
// Parse the JSON body
var parsedBody Body
if err := json.Unmarshal(body, &parsedBody); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
// Validate individual fields
if parsedBody.AlertSourceConfigID != scenario.ExpectedAlertSourceID {
t.Errorf("expected alert_source_config_id to be %s, got %s", scenario.ExpectedAlertSourceID, parsedBody.AlertSourceConfigID)
}
if parsedBody.Status != scenario.ExpectedStatus {
t.Errorf("expected status to be %s, got %s", scenario.ExpectedStatus, parsedBody.Status)
}
if parsedBody.Title != scenario.ExpectedTitle {
t.Errorf("expected title to be %s, got %s", scenario.ExpectedTitle, parsedBody.Title)
}
if parsedBody.Description != scenario.ExpectedDescription {
t.Errorf("expected description to be %s, got %s", scenario.ExpectedDescription, parsedBody.Description)
}
if scenario.ExpectedSourceURL != "" && parsedBody.SourceURL != scenario.ExpectedSourceURL {
t.Errorf("expected source_url to be %s, got %s", scenario.ExpectedSourceURL, parsedBody.SourceURL)
}
if scenario.ExpectedMetadata != nil {
metadataJSON, _ := json.Marshal(parsedBody.Metadata)
expectedMetadataJSON, _ := json.Marshal(scenario.ExpectedMetadata)
if string(metadataJSON) != string(expectedMetadataJSON) {
t.Errorf("expected metadata to be %s, got %s", string(expectedMetadataJSON), string(metadataJSON))
}
}
// Validate that deduplication_key exists and is not empty
if scenario.ShouldHaveDeduplicationKey {
if parsedBody.DeduplicationKey == "" {
t.Error("expected deduplication_key to be present and non-empty")
}
}
})
}
}
@@ -1,4 +1,4 @@
package jetbrainsspace
package n8n
import (
"bytes"
@@ -15,50 +15,36 @@ import (
)
var (
ErrProjectNotSet = errors.New("project not set")
ErrChannelIDNotSet = errors.New("channel-id not set")
ErrTokenNotSet = errors.New("token not set")
ErrWebhookURLNotSet = errors.New("webhook-url not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
Project string `yaml:"project"` // Project name
ChannelID string `yaml:"channel-id"` // Chat Channel ID
Token string `yaml:"token"` // Bearer Token
WebhookURL string `yaml:"webhook-url"`
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
}
func (cfg *Config) Validate() error {
if len(cfg.Project) == 0 {
return ErrProjectNotSet
}
if len(cfg.ChannelID) == 0 {
return ErrChannelIDNotSet
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
if len(cfg.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.Project) > 0 {
cfg.Project = override.Project
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
if len(override.ChannelID) > 0 {
cfg.ChannelID = override.ChannelID
}
if len(override.Token) > 0 {
cfg.Token = override.Token
if len(override.Title) > 0 {
cfg.Title = override.Title
}
}
// AlertProvider is the configuration necessary for sending an alert using JetBrains Space
// AlertProvider is the configuration necessary for sending an alert using n8n
type AlertProvider struct {
DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"`
}
@@ -90,13 +76,11 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
url := fmt.Sprintf("https://%s.jetbrains.space/api/http/chats/messages/send-message", cfg.Project)
request, err := http.NewRequest(http.MethodPost, url, buffer)
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+cfg.Token)
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
@@ -110,78 +94,50 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
}
type Body struct {
Channel string `json:"channel"`
Content Content `json:"content"`
Title string `json:"title"`
EndpointName string `json:"endpoint_name"`
EndpointGroup string `json:"endpoint_group,omitempty"`
EndpointURL string `json:"endpoint_url"`
AlertDescription string `json:"alert_description,omitempty"`
Resolved bool `json:"resolved"`
Message string `json:"message"`
ConditionResults []ConditionResult `json:"condition_results,omitempty"`
}
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"`
type ConditionResult struct {
Condition string `json:"condition"`
Success bool `json:"success"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
body := Body{
Channel: "id:" + cfg.ChannelID,
Content: Content{
ClassName: "ChatMessage.Block",
Sections: []Section{{
ClassName: "MessageSection",
Elements: []Element{},
}},
},
}
var message string
if resolved {
body.Content.Style = "SUCCESS"
body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else {
body.Content.Style = "WARNING"
body.Content.Sections[0].Header = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
title := "Gatus"
if cfg.Title != "" {
title = cfg.Title
}
var conditionResults []ConditionResult
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,
conditionResults = append(conditionResults, ConditionResult{
Condition: conditionResult.Condition,
Success: conditionResult.Success,
})
}
body := Body{
Title: title,
EndpointName: ep.Name,
EndpointGroup: ep.Group,
EndpointURL: ep.URL,
AlertDescription: alert.GetDescription(),
Resolved: resolved,
Message: message,
ConditionResults: conditionResults,
}
bodyAsJSON, _ := json.Marshal(body)
return bodyAsJSON
}
@@ -1,4 +1,4 @@
package jetbrainsspace
package n8n
import (
"encoding/json"
@@ -12,11 +12,11 @@ import (
)
func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{DefaultConfig: Config{Project: ""}}
invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{DefaultConfig: Config{Project: "foo", ChannelID: "bar", Token: "baz"}}
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "https://example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid")
}
@@ -24,10 +24,9 @@ func TestAlertProvider_Validate(t *testing.T) {
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
DefaultConfig: Config{Project: "foobar"},
Overrides: []Override{
{
Config: Config{ChannelID: "http://example.com"},
Config: Config{WebhookURL: "http://example.com"},
Group: "",
},
},
@@ -36,26 +35,21 @@ func TestAlertProvider_ValidateWithOverride(t *testing.T) {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
DefaultConfig: Config{Project: "foobar"},
Overrides: []Override{
{
Config: Config{ChannelID: ""},
Config: Config{WebhookURL: ""},
Group: "group",
},
},
}
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
t.Error("provider webhook URL shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{
Project: "foo",
ChannelID: "bar",
Token: "baz",
},
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Config: Config{ChannelID: "foobar"},
Config: Config{WebhookURL: "http://example.com"},
Group: "group",
},
},
@@ -79,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -89,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -99,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -109,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
},
{
Name: "resolved-error",
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -151,45 +145,94 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Endpoint endpoint.Endpoint
Alert alert.Alert
Resolved bool
ExpectedBody string
ExpectedBody Body
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been triggered due to having failed 3 time(s) in a row"}]}}`,
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name", URL: "https://example.org"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: Body{
Title: "Gatus",
EndpointName: "name",
EndpointURL: "https://example.org",
AlertDescription: "description-1",
Resolved: false,
Message: "An alert for name has been triggered due to having failed 3 time(s) in a row",
ConditionResults: []ConditionResult{
{Condition: "[CONNECTED] == true", Success: false},
{Condition: "[STATUS] == 200", Success: false},
},
},
},
{
Name: "triggered-with-group",
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row"}]}}`,
Name: "triggered-with-group",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group", URL: "https://example.org"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: Body{
Title: "Gatus",
EndpointName: "name",
EndpointGroup: "group",
EndpointURL: "https://example.org",
AlertDescription: "description-1",
Resolved: false,
Message: "An alert for group/name has been triggered due to having failed 3 time(s) in a row",
ConditionResults: []ConditionResult{
{Condition: "[CONNECTED] == true", Success: false},
{Condition: "[STATUS] == 200", Success: false},
},
},
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been resolved after passing successfully 5 time(s) in a row"}]}}`,
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Endpoint: endpoint.Endpoint{Name: "name", URL: "https://example.org"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: Body{
Title: "Gatus",
EndpointName: "name",
EndpointURL: "https://example.org",
AlertDescription: "description-2",
Resolved: true,
Message: "An alert for name has been resolved after passing successfully 5 time(s) in a row",
ConditionResults: []ConditionResult{
{Condition: "[CONNECTED] == true", Success: true},
{Condition: "[STATUS] == 200", Success: true},
},
},
},
{
Name: "resolved-with-group",
Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row"}]}}`,
Name: "resolved-with-custom-title",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com", Title: "Custom Title"}},
Endpoint: endpoint.Endpoint{Name: "name", URL: "https://example.org"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: Body{
Title: "Custom Title",
EndpointName: "name",
EndpointURL: "https://example.org",
AlertDescription: "description-2",
Resolved: true,
Message: "An alert for name has been resolved after passing successfully 5 time(s) in a row",
ConditionResults: []ConditionResult{
{Condition: "[CONNECTED] == true", Success: true},
{Condition: "[STATUS] == 200", Success: true},
},
},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg, err := scenario.Provider.GetConfig(scenario.Endpoint.Group, &scenario.Alert)
if err != nil {
t.Fatal("couldn't get config:", err.Error())
}
body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
cfg,
&scenario.Endpoint,
&scenario.Alert,
&endpoint.Result{
@@ -200,13 +243,22 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
},
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 {
var actualBody Body
if err := json.Unmarshal(body, &actualBody); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
if actualBody.Title != scenario.ExpectedBody.Title {
t.Errorf("expected title to be %s, got %s", scenario.ExpectedBody.Title, actualBody.Title)
}
if actualBody.EndpointName != scenario.ExpectedBody.EndpointName {
t.Errorf("expected endpoint name to be %s, got %s", scenario.ExpectedBody.EndpointName, actualBody.EndpointName)
}
if actualBody.Resolved != scenario.ExpectedBody.Resolved {
t.Errorf("expected resolved to be %v, got %v", scenario.ExpectedBody.Resolved, actualBody.Resolved)
}
if actualBody.Message != scenario.ExpectedBody.Message {
t.Errorf("expected message to be %s, got %s", scenario.ExpectedBody.Message, actualBody.Message)
}
})
}
}
@@ -231,67 +283,67 @@ func TestAlertProvider_GetConfig(t *testing.T) {
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{ChannelID: "group-channel"},
Config: Config{WebhookURL: "http://example01.com"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{ChannelID: "group-channel"},
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "group-channel", Project: "project", Token: "token"},
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{ChannelID: "group-channel"},
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"channel-id": "alert-channel", "project": "alert-project", "token": "alert-token"}},
ExpectedOutput: Config{ChannelID: "alert-channel", Project: "alert-project", Token: "alert-token"},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
},
}
for _, scenario := range scenarios {
@@ -300,14 +352,8 @@ func TestAlertProvider_GetConfig(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.ChannelID != scenario.ExpectedOutput.ChannelID {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.ChannelID, got.ChannelID)
}
if got.Project != scenario.ExpectedOutput.Project {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Project, got.Project)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Token, got.Token)
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
+6 -3
View File
@@ -3,6 +3,7 @@ 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/clickup"
"github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
"github.com/TwiN/gatus/v5/alerting/provider/discord"
@@ -16,11 +17,11 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
"github.com/TwiN/gatus/v5/alerting/provider/line"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
"github.com/TwiN/gatus/v5/alerting/provider/n8n"
"github.com/TwiN/gatus/v5/alerting/provider/newrelic"
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
@@ -92,6 +93,7 @@ func MergeProviderDefaultAlertIntoEndpointAlert(providerDefaultAlert, endpointAl
var (
// Validate provider interface implementation on compile
_ AlertProvider = (*awsses.AlertProvider)(nil)
_ AlertProvider = (*clickup.AlertProvider)(nil)
_ AlertProvider = (*custom.AlertProvider)(nil)
_ AlertProvider = (*datadog.AlertProvider)(nil)
_ AlertProvider = (*discord.AlertProvider)(nil)
@@ -105,11 +107,11 @@ var (
_ AlertProvider = (*ifttt.AlertProvider)(nil)
_ AlertProvider = (*ilert.AlertProvider)(nil)
_ AlertProvider = (*incidentio.AlertProvider)(nil)
_ AlertProvider = (*jetbrainsspace.AlertProvider)(nil)
_ AlertProvider = (*line.AlertProvider)(nil)
_ AlertProvider = (*matrix.AlertProvider)(nil)
_ AlertProvider = (*mattermost.AlertProvider)(nil)
_ AlertProvider = (*messagebird.AlertProvider)(nil)
_ AlertProvider = (*n8n.AlertProvider)(nil)
_ AlertProvider = (*newrelic.AlertProvider)(nil)
_ AlertProvider = (*ntfy.AlertProvider)(nil)
_ AlertProvider = (*opsgenie.AlertProvider)(nil)
@@ -133,6 +135,7 @@ var (
// Validate config interface implementation on compile
_ Config[awsses.Config] = (*awsses.Config)(nil)
_ Config[clickup.Config] = (*clickup.Config)(nil)
_ Config[custom.Config] = (*custom.Config)(nil)
_ Config[datadog.Config] = (*datadog.Config)(nil)
_ Config[discord.Config] = (*discord.Config)(nil)
@@ -146,11 +149,11 @@ var (
_ Config[ifttt.Config] = (*ifttt.Config)(nil)
_ Config[ilert.Config] = (*ilert.Config)(nil)
_ Config[incidentio.Config] = (*incidentio.Config)(nil)
_ Config[jetbrainsspace.Config] = (*jetbrainsspace.Config)(nil)
_ Config[line.Config] = (*line.Config)(nil)
_ Config[matrix.Config] = (*matrix.Config)(nil)
_ Config[mattermost.Config] = (*mattermost.Config)(nil)
_ Config[messagebird.Config] = (*messagebird.Config)(nil)
_ Config[n8n.Config] = (*n8n.Config)(nil)
_ Config[newrelic.Config] = (*newrelic.Config)(nil)
_ Config[ntfy.Config] = (*ntfy.Config)(nil)
_ Config[opsgenie.Config] = (*opsgenie.Config)(nil)
@@ -166,7 +166,10 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
Value: conditionResult.Condition,
})
}
var description string
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = "**Description**: " + alertDescription
}
cardContent := AdaptiveCardBody{
Type: "AdaptiveCard",
Version: "1.4", // Version 1.5 and 1.6 doesn't seem to be supported by Teams as of 27/08/2024
@@ -190,6 +193,11 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
Text: message,
Wrap: true,
},
{
Type: "TextBlock",
Text: description,
Wrap: true,
},
{
Type: "FactSet",
Facts: facts,
@@ -152,14 +152,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"❌\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"❌\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Attention\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row.\",\"wrap\":true},{\"type\":\"TextBlock\",\"text\":\"**Description**: description-1\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"❌\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"❌\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Attention\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"✅\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"✅\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"TextBlock\",\"text\":\"**Description**: description-2\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"✅\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"✅\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
},
{
Name: "resolved-with-no-conditions",
@@ -167,7 +167,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"TextBlock\",\"text\":\"**Description**: description-2\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
},
}
for _, scenario := range scenarios {
+1 -1
View File
@@ -147,7 +147,7 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
}
var text string
if len(alert.GetDescription()) > 0 {
text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Description* \n_%s_ \n%s", message, alert.GetDescription(), formattedConditionResults)
text = fmt.Sprintf("⛑ *Gatus* \n%s \n*Description* \n%s \n%s", message, alert.GetDescription(), formattedConditionResults)
} else {
text = fmt.Sprintf("⛑ *Gatus* \n%s%s", message, formattedConditionResults)
}
+12 -4
View File
@@ -124,6 +124,7 @@ func TestAlertProvider_Send(t *testing.T) {
func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
descriptionWithLink := "[link](https://example.org/)"
scenarios := []struct {
Name string
Provider AlertProvider
@@ -137,14 +138,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\ndescription-1 \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
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* \\ndescription-2 \\n\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
},
{
Name: "resolved-with-no-conditions",
@@ -152,14 +153,21 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\",\"parse_mode\":\"MARKDOWN\"}",
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* \\ndescription-2 \\n\",\"parse_mode\":\"MARKDOWN\"}",
},
{
Name: "send to topic",
Provider: AlertProvider{DefaultConfig: Config{ID: "123", TopicID: "7"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\",\"message_thread_id\":\"7\"}",
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\ndescription-1 \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\",\"message_thread_id\":\"7\"}",
},
{
Name: "triggered with link in description",
Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
Alert: alert.Alert{Description: &descriptionWithLink, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n[link](https://example.org/) \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
},
}
for _, scenario := range scenarios {
+20 -5
View File
@@ -7,6 +7,7 @@ import (
"io"
"net/http"
"net/url"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
@@ -23,10 +24,14 @@ var (
)
type Config struct {
BotEmail string `yaml:"bot-email"` // Email of the bot user
BotAPIKey string `yaml:"bot-api-key"` // API key of the bot user
Domain string `yaml:"domain"` // Domain of the Zulip server
ChannelID string `yaml:"channel-id"` // ID of the channel to send the message to
BotEmail string `yaml:"bot-email"` // Email of the bot user
BotAPIKey string `yaml:"bot-api-key"` // API key of the bot user
Domain string `yaml:"domain"` // Domain of the Zulip server
ChannelID string `yaml:"channel-id"` // ID of the channel to send the message to
Topic string `yaml:"topic,omitempty"` // Topic to send the message to; defaults to "Gatus" when not set
// The following placeholders are supported in Topic:
// [ENDPOINT_NAME], [ENDPOINT_GROUP], [ALERT_DESCRIPTION]
}
func (cfg *Config) Validate() error {
@@ -58,6 +63,9 @@ func (cfg *Config) Merge(override *Config) {
if len(override.ChannelID) > 0 {
cfg.ChannelID = override.ChannelID
}
if len(override.Topic) > 0 {
cfg.Topic = override.Topic
}
}
// AlertProvider is the configuration necessary for sending an alert using Zulip
@@ -138,10 +146,17 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
}
message += fmt.Sprintf("\n%s - `%s`", prefix, conditionResult.Condition)
}
topic := cfg.Topic
if len(topic) == 0 {
topic = "Gatus"
}
topic = strings.ReplaceAll(topic, "[ENDPOINT_NAME]", ep.Name)
topic = strings.ReplaceAll(topic, "[ENDPOINT_GROUP]", ep.Group)
topic = strings.ReplaceAll(topic, "[ALERT_DESCRIPTION]", alert.GetDescription())
return url.Values{
"type": {"channel"},
"to": {cfg.ChannelID},
"topic": {"Gatus"},
"topic": {topic},
"content": {message},
}.Encode()
}
+51
View File
@@ -128,6 +128,52 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
"type": {"channel"},
},
},
{
name: "Failed alert with custom topic",
provider: AlertProvider{
DefaultConfig: Config{
BotEmail: "bot-email",
BotAPIKey: "bot-api-key",
Domain: "domain",
ChannelID: "channel-id",
Topic: "alerts",
},
},
alert: basicAlert,
resolved: false,
hasConditions: false,
expectedBody: url.Values{
"content": {`An alert for **endpoint-Name** has been triggered due to having failed 3 time(s) in a row
> Description
`},
"to": {"channel-id"},
"topic": {"alerts"},
"type": {"channel"},
},
},
{
name: "Failed alert with templated topic",
provider: AlertProvider{
DefaultConfig: Config{
BotEmail: "bot-email",
BotAPIKey: "bot-api-key",
Domain: "domain",
ChannelID: "channel-id",
Topic: "[ENDPOINT_NAME]",
},
},
alert: basicAlert,
resolved: false,
hasConditions: false,
expectedBody: url.Values{
"content": {`An alert for **endpoint-Name** has been triggered due to having failed 3 time(s) in a row
> Description
`},
"to": {"channel-id"},
"topic": {"endpoint-Name"},
"type": {"channel"},
},
},
{
name: "Resolved alert with conditions",
provider: AlertProvider{
@@ -446,12 +492,14 @@ func TestAlertProvider_GetConfig(t *testing.T) {
"bot-api-key": "alert-bot-api-key",
"domain": "alert-domain",
"channel-id": "alert-channel-id",
"topic": "alert-topic",
}},
ExpectedOutput: Config{
BotEmail: "alert-bot-email",
BotAPIKey: "alert-bot-api-key",
Domain: "alert-domain",
ChannelID: "alert-channel-id",
Topic: "alert-topic",
},
},
}
@@ -473,6 +521,9 @@ func TestAlertProvider_GetConfig(t *testing.T) {
if got.ChannelID != scenario.ExpectedOutput.ChannelID {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.ChannelID, got.ChannelID)
}
if got.Topic != scenario.ExpectedOutput.Topic {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Topic, got.Topic)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
+1
View File
@@ -84,6 +84,7 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration", ResponseTimeRaw)
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/badge.svg", ResponseTimeBadge(cfg))
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/chart.svg", ResponseTimeChart)
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/history", ResponseTimeHistory)
// This endpoint requires authz with bearer token, so technically it is protected
unprotectedAPIRouter.Post("/v1/endpoints/:key/external", CreateExternalEndpointResult(cfg))
// SPA
+1 -1
View File
@@ -299,7 +299,7 @@ func getBadgeColorFromResponseTime(responseTime int, key string, cfg *config.Con
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++ {
for i := range 5 {
if responseTime <= thresholds[i] {
return badgeColors[i]
}
+60
View File
@@ -126,3 +126,63 @@ func ResponseTimeChart(c *fiber.Ctx) error {
}
return nil
}
func ResponseTimeHistory(c *fiber.Ctx) error {
duration := c.Params("duration")
var from time.Time
switch duration {
case "30d":
from = time.Now().Truncate(time.Hour).Add(-30 * 24 * time.Hour)
case "7d":
from = time.Now().Truncate(time.Hour).Add(-7 * 24 * time.Hour)
case "24h":
from = time.Now().Truncate(time.Hour).Add(-24 * time.Hour)
default:
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h")
}
endpointKey, err := url.QueryUnescape(c.Params("key"))
if err != nil {
return c.Status(400).SendString("invalid key encoding")
}
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(endpointKey, from, time.Now())
if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
}
if errors.Is(err, common.ErrInvalidTimeRange) {
return c.Status(400).SendString(err.Error())
}
return c.Status(500).SendString(err.Error())
}
if len(hourlyAverageResponseTime) == 0 {
return c.Status(200).JSON(map[string]interface{}{
"timestamps": []int64{},
"values": []int{},
})
}
hourlyTimestamps := make([]int, 0, len(hourlyAverageResponseTime))
earliestTimestamp := int64(0)
for hourlyTimestamp := range hourlyAverageResponseTime {
hourlyTimestamps = append(hourlyTimestamps, int(hourlyTimestamp))
if earliestTimestamp == 0 || hourlyTimestamp < earliestTimestamp {
earliestTimestamp = hourlyTimestamp
}
}
for earliestTimestamp > from.Unix() {
earliestTimestamp -= int64(time.Hour.Seconds())
hourlyTimestamps = append(hourlyTimestamps, int(earliestTimestamp))
}
sort.Ints(hourlyTimestamps)
timestamps := make([]int64, 0, len(hourlyTimestamps))
values := make([]int, 0, len(hourlyTimestamps))
for _, hourlyTimestamp := range hourlyTimestamps {
timestamp := int64(hourlyTimestamp)
averageResponseTime := hourlyAverageResponseTime[timestamp]
timestamps = append(timestamps, timestamp*1000)
values = append(values, averageResponseTime)
}
return c.Status(http.StatusOK).JSON(map[string]interface{}{
"timestamps": timestamps,
"values": values,
})
}
+66
View File
@@ -81,3 +81,69 @@ func TestResponseTimeChart(t *testing.T) {
})
}
}
func TestResponseTimeHistory(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
cfg := &config.Config{
Metrics: true,
Endpoints: []*endpoint.Endpoint{
{
Name: "frontend",
Group: "core",
},
{
Name: "backend",
Group: "core",
},
},
}
watchdog.UpdateEndpointStatus(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
watchdog.UpdateEndpointStatus(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
api := New(cfg)
router := api.Router()
type Scenario struct {
Name string
Path string
ExpectedCode int
}
scenarios := []Scenario{
{
Name: "history-response-time-24h",
Path: "/api/v1/endpoints/core_backend/response-times/24h/history",
ExpectedCode: http.StatusOK,
},
{
Name: "history-response-time-7d",
Path: "/api/v1/endpoints/core_frontend/response-times/7d/history",
ExpectedCode: http.StatusOK,
},
{
Name: "history-response-time-30d",
Path: "/api/v1/endpoints/core_frontend/response-times/30d/history",
ExpectedCode: http.StatusOK,
},
{
Name: "history-response-time-with-invalid-duration",
Path: "/api/v1/endpoints/core_backend/response-times/3d/history",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "history-response-time-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/response-times/7d/history",
ExpectedCode: http.StatusNotFound,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
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)
}
})
}
}
+2 -1
View File
@@ -3,6 +3,7 @@ package api
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/TwiN/gatus/v5/security"
@@ -27,7 +28,7 @@ func TestConfigHandler_ServeHTTP(t *testing.T) {
t.Error("expected err to be nil, but was", err)
}
// Test the config handler
request, _ := http.NewRequest("GET", "/api/v1/config", http.NoBody)
request := httptest.NewRequest("GET", "/api/v1/config", http.NoBody)
response, err := app.Test(request)
if err != nil {
t.Error("expected err to be nil, but was", err)
+10 -1
View File
@@ -68,11 +68,20 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
return c.Status(500).SendString(err.Error())
}
logr.Infof("[api.CreateExternalEndpointResult] Successfully inserted result for external endpoint with key=%s and success=%s", c.Params("key"), success)
inEndpointMaintenanceWindow := false
for _, maintenanceWindow := range externalEndpoint.MaintenanceWindows {
if maintenanceWindow.IsUnderMaintenance() {
logr.Debug("[api.CreateExternalEndpointResult] Under endpoint maintenance window")
inEndpointMaintenanceWindow = true
}
}
// Check if an alert should be triggered or resolved
if !cfg.Maintenance.IsUnderMaintenance() {
if !cfg.Maintenance.IsUnderMaintenance() && !inEndpointMaintenanceWindow {
watchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting)
externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow
externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
} else {
logr.Debug("[api.CreateExternalEndpointResult] Not handling alerting because currently in the maintenance window")
}
if cfg.Metrics {
metrics.PublishMetricsForEndpoint(convertedEndpoint, result, extraLabels)
+9 -2
View File
@@ -3,6 +3,7 @@ package api
import (
"errors"
"fmt"
"net/url"
"time"
"github.com/TwiN/gatus/v5/storage/store"
@@ -25,7 +26,10 @@ func UptimeRaw(c *fiber.Ctx) error {
default:
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
}
key := c.Params("key")
key, err := url.QueryUnescape(c.Params("key"))
if err != nil {
return c.Status(400).SendString("invalid key encoding")
}
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
@@ -57,7 +61,10 @@ func ResponseTimeRaw(c *fiber.Ctx) error {
default:
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
}
key := c.Params("key")
key, err := url.QueryUnescape(c.Params("key"))
if err != nil {
return c.Status(400).SendString("invalid key encoding")
}
responseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
+90 -43
View File
@@ -1,17 +1,19 @@
package client
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/smtp"
"os"
"runtime"
"strings"
"time"
@@ -19,13 +21,13 @@ import (
"github.com/TwiN/gocache/v2"
"github.com/TwiN/logr"
"github.com/TwiN/whois"
"github.com/gorilla/websocket"
"github.com/ishidawataru/sctp"
"github.com/miekg/dns"
ping "github.com/prometheus-community/pro-bing"
"github.com/registrobr/rdap"
"github.com/registrobr/rdap/protocol"
"golang.org/x/crypto/ssh"
"golang.org/x/net/websocket"
)
const (
@@ -246,7 +248,7 @@ func CanPerformTLS(address string, body string, config *Config) (connected bool,
// 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) {
func CanCreateSSHConnection(address, username, password, privateKey string, config *Config) (bool, *ssh.Client, error) {
var port string
if strings.Contains(address, ":") {
addressAndPort := strings.Split(address, ":")
@@ -258,13 +260,25 @@ func CanCreateSSHConnection(address, username, password string, config *Config)
} else {
port = "22"
}
cli, err := ssh.Dial("tcp", strings.Join([]string{address, port}, ":"), &ssh.ClientConfig{
// Build auth methods: prefer parsed private key if provided, fall back to password.
var authMethods []ssh.AuthMethod
if len(privateKey) > 0 {
if signer, err := ssh.ParsePrivateKey([]byte(privateKey)); err == nil {
authMethods = append(authMethods, ssh.PublicKeys(signer))
} else {
return false, nil, fmt.Errorf("invalid private key: %w", err)
}
}
if len(password) > 0 {
authMethods = append(authMethods, ssh.Password(password))
}
cli, err := ssh.Dial("tcp", net.JoinHostPort(address, port), &ssh.ClientConfig{
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
User: username,
Auth: []ssh.AuthMethod{
ssh.Password(password),
},
Timeout: config.Timeout,
Auth: authMethods,
Timeout: config.Timeout,
})
if err != nil {
return false, nil, err
@@ -301,7 +315,7 @@ func CheckSSHBanner(address string, cfg *Config) (bool, int, error) {
}
// ExecuteSSHCommand executes a command to an address using the SSH protocol.
func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool, int, error) {
func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool, int, []byte, error) {
type Body struct {
Command string `json:"command"`
}
@@ -309,26 +323,30 @@ func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool
var b Body
body = parseLocalAddressPlaceholder(body, sshClient.Conn.LocalAddr())
if err := json.Unmarshal([]byte(body), &b); err != nil {
return false, 0, err
return false, 0, nil, err
}
sess, err := sshClient.NewSession()
if err != nil {
return false, 0, err
return false, 0, nil, err
}
// Capture stdout
var stdout bytes.Buffer
sess.Stdout = &stdout
err = sess.Start(b.Command)
if err != nil {
return false, 0, err
return false, 0, nil, err
}
defer sess.Close()
err = sess.Wait()
output := stdout.Bytes()
if err == nil {
return true, 0, nil
return true, 0, output, nil
}
var exitErr *ssh.ExitError
if ok := errors.As(err, &exitErr); !ok {
return false, 0, err
return false, 0, nil, err
}
return true, exitErr.ExitStatus(), nil
return true, exitErr.ExitStatus(), output, nil
}
// Ping checks if an address can be pinged and returns the round-trip time if the address can be pinged
@@ -338,12 +356,7 @@ func Ping(address string, config *Config) (bool, time.Duration) {
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/prometheus-community/pro-bing#linux
pinger.SetPrivileged(runtime.GOOS != "darwin")
pinger.SetPrivileged(ShouldRunPingerAsPrivileged())
pinger.SetNetwork(config.Network)
err := pinger.Run()
if err != nil {
@@ -359,51 +372,75 @@ func Ping(address string, config *Config) (bool, time.Duration) {
return true, 0
}
// ShouldRunPingerAsPrivileged will determine whether or not to run pinger in privileged mode.
// It should be set to privileged when running as root, and always on windows. See https://pkg.go.dev/github.com/macrat/go-parallel-pinger#Pinger.SetPrivileged
func ShouldRunPingerAsPrivileged() bool {
// Set the pinger's privileged mode to false for darwin
// See https://github.com/TwiN/gatus/issues/132
// linux should also be set to false, but there are potential complications
// See https://github.com/TwiN/gatus/pull/748 and https://github.com/TwiN/gatus/issues/697#issuecomment-2081700989
//
// Note that for this to work on Linux, Gatus must run with sudo privileges. (in certain cases)
// See https://github.com/prometheus-community/pro-bing#linux
if runtime.GOOS == "windows" {
return true
}
// To actually check for cap_net_raw capabilities, we would need to add "kernel.org/pub/linux/libs/security/libcap/cap" to gatus.
// Or use a syscall and check for permission errors, but this requires platform specific compilation
// As a backstop we can simply check the effective user id and run as privileged when running as root
return os.Geteuid() == 0
}
// QueryWebSocket opens a websocket connection, write `body` and return a message from the server
func QueryWebSocket(address, body string, headers map[string]string, config *Config) (bool, []byte, error) {
const (
Origin = "http://localhost/"
MaximumMessageSize = 1024 // in bytes
Origin = "http://localhost/"
)
wsConfig, err := websocket.NewConfig(address, Origin)
if err != nil {
return false, nil, fmt.Errorf("error configuring websocket connection: %w", err)
}
if headers != nil {
if wsConfig.Header == nil {
wsConfig.Header = make(http.Header)
}
for name, value := range headers {
wsConfig.Header.Set(name, value)
var (
dialer = websocket.Dialer{
EnableCompression: true,
}
wsHeaders = make(http.Header)
)
wsHeaders.Set("Origin", Origin)
for name, value := range headers {
wsHeaders.Set(name, value)
}
ctx := context.Background()
if config != nil {
wsConfig.Dialer = &net.Dialer{Timeout: config.Timeout}
wsConfig.TlsConfig = &tls.Config{
if config.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, config.Timeout)
defer cancel()
}
dialer.TLSClientConfig = &tls.Config{
InsecureSkipVerify: config.Insecure,
}
if config.HasTLSConfig() && config.TLS.isValid() == nil {
wsConfig.TlsConfig = configureTLS(wsConfig.TlsConfig, *config.TLS)
dialer.TLSClientConfig = configureTLS(dialer.TLSClientConfig, *config.TLS)
}
}
// Dial URL
ws, err := websocket.DialConfig(wsConfig)
ws, _, err := dialer.DialContext(ctx, address, wsHeaders)
if err != nil {
return false, nil, fmt.Errorf("error dialing websocket: %w", err)
}
defer ws.Close()
body = parseLocalAddressPlaceholder(body, ws.LocalAddr())
// Write message
if _, err := ws.Write([]byte(body)); err != nil {
if err := ws.WriteMessage(websocket.TextMessage, []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 {
msgType, msg, err := ws.ReadMessage()
if err != nil {
return false, nil, fmt.Errorf("error reading websocket message: %w", err)
} else if msgType != websocket.TextMessage && msgType != websocket.BinaryMessage {
return false, nil, fmt.Errorf("unexpected websocket message type: %d, expected %d or %d", msgType, websocket.TextMessage, websocket.BinaryMessage)
}
return true, msg[:n], nil
return true, msg, nil
}
func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string, body []byte, err error) {
@@ -462,6 +499,13 @@ func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string
if srv, ok := rr.(*dns.SRV); ok {
body = []byte(fmt.Sprintf("%s:%d", srv.Target, srv.Port))
}
case dns.TypeTXT:
if txt, ok := rr.(*dns.TXT); ok {
if len(body) > 0 {
body = append(body, '\n')
}
body = append(body, []byte(strings.Join(txt.Txt, ""))...)
}
default:
body = []byte("query type is not supported yet")
}
@@ -491,6 +535,9 @@ func rdapQuery(hostname string) (*whois.Response, error) {
break
}
}
if response.ExpirationDate.IsZero() {
return nil, fmt.Errorf("no expiration event found in RDAP response for %s", hostname)
}
return &response, nil
}
@@ -516,4 +563,4 @@ func reverseNameForIP(ipStr string) (string, error) {
nibbles[i], nibbles[j] = nibbles[j], nibbles[i]
}
return strings.Join(nibbles, ".") + ".ip6.arpa.", nil
}
}
+87 -19
View File
@@ -6,6 +6,9 @@ import (
"io"
"net/http"
"net/netip"
"os"
"runtime"
"strings"
"testing"
"time"
@@ -14,6 +17,17 @@ import (
"github.com/TwiN/gatus/v5/test"
)
func isIgnorableNetworkTestError(err error) bool {
if err == nil {
return false
}
errString := err.Error()
return strings.Contains(errString, "no such host") ||
strings.Contains(errString, "connection reset by peer") ||
strings.Contains(errString, "i/o timeout") ||
strings.Contains(errString, "server misbehaving")
}
func TestGetHTTPClient(t *testing.T) {
cfg := &Config{
Insecure: false,
@@ -40,6 +54,7 @@ func TestGetHTTPClient(t *testing.T) {
}
func TestRdapQuery(t *testing.T) {
t.Parallel()
if _, err := rdapQuery("1.1.1.1"); err == nil {
t.Error("expected an error due to the invalid domain type")
}
@@ -51,6 +66,13 @@ func TestRdapQuery(t *testing.T) {
} else if response.ExpirationDate.Unix() <= 0 {
t.Error("expected to have a valid expiry date, got", response.ExpirationDate.Unix())
}
// .net domain: rdapQuery must either return a valid expiration or an error,
// but never silently return a zero-value expiration date (the bug in #1570)
if response, err := rdapQuery("google.net"); err != nil {
t.Logf("rdapQuery returned error for .net domain (fallback to WHOIS expected): %s", err)
} else if response.ExpirationDate.IsZero() {
t.Error("rdapQuery returned a zero expiration date without an error for .net domain")
}
}
func TestGetDomainExpiration(t *testing.T) {
@@ -79,6 +101,12 @@ func TestGetDomainExpiration(t *testing.T) {
} else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0")
}
// .net domain: should succeed via RDAP or WHOIS fallback (#1570)
if domainExpiration, err := GetDomainExpiration("google.net"); err != nil {
t.Errorf("expected error to be nil for .net domain, but got: `%s`", err)
} else if domainExpiration <= 0 {
t.Error("expected domain expiration to be higher than 0 for .net domain")
}
}
func TestPing(t *testing.T) {
@@ -129,10 +157,36 @@ func TestPing(t *testing.T) {
}
}
func TestShouldRunPingerAsPrivileged(t *testing.T) {
// Don't run in parallel since we're testing system-dependent behavior
if runtime.GOOS == "windows" {
result := ShouldRunPingerAsPrivileged()
if !result {
t.Error("On Windows, ShouldRunPingerAsPrivileged() should return true")
}
return
}
// Non-Windows tests
result := ShouldRunPingerAsPrivileged()
isRoot := os.Geteuid() == 0
// Test cases based on current environment
if isRoot {
if !result {
t.Error("When running as root, ShouldRunPingerAsPrivileged() should return true")
}
} else {
// When not root, the result depends on raw socket creation
// We can at least verify the function runs without panic
t.Logf("Non-root privileged result: %v", result)
}
}
func TestCanPerformStartTLS(t *testing.T) {
type args struct {
address string
insecure bool
address string
insecure bool
dnsresolver string
}
tests := []struct {
@@ -168,7 +222,7 @@ func TestCanPerformStartTLS(t *testing.T) {
{
name: "dns resolver",
args: args{
address: "smtp.gmail.com:587",
address: "smtp.gmail.com:587",
dnsresolver: "tcp://1.1.1.1:53",
},
wantConnected: true,
@@ -179,6 +233,9 @@ func TestCanPerformStartTLS(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
connected, _, err := CanPerformStartTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second, DNSResolver: tt.args.dnsresolver})
if !tt.wantErr && isIgnorableNetworkTestError(err) {
t.Skipf("skipping due to transient network error: %v", err)
}
if (err != nil) != tt.wantErr {
t.Errorf("CanPerformStartTLS() err=%v, wantErr=%v", err, tt.wantErr)
return
@@ -248,6 +305,9 @@ func TestCanPerformTLS(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
connected, _, _, err := CanPerformTLS(tt.args.address, "", &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
if !tt.wantErr && isIgnorableNetworkTestError(err) {
t.Skipf("skipping due to transient network error: %v", err)
}
if (err != nil) != tt.wantErr {
t.Errorf("CanPerformTLS() err=%v, wantErr=%v", err, tt.wantErr)
return
@@ -260,6 +320,7 @@ func TestCanPerformTLS(t *testing.T) {
}
func TestCanCreateConnection(t *testing.T) {
t.Parallel()
connected, _ := CanCreateNetworkConnection("tcp", "127.0.0.1", "", &Config{Timeout: 5 * time.Second})
if connected {
t.Error("should've failed, because there's no port in the address")
@@ -329,6 +390,7 @@ func TestHttpClientProvidesOAuth2BearerToken(t *testing.T) {
}
func TestQueryWebSocket(t *testing.T) {
t.Parallel()
_, _, err := QueryWebSocket("", "body", nil, &Config{Timeout: 2 * time.Second})
if err == nil {
t.Error("expected an error due to the address being invalid")
@@ -340,7 +402,8 @@ func TestQueryWebSocket(t *testing.T) {
}
func TestTlsRenegotiation(t *testing.T) {
tests := []struct {
t.Parallel()
scenarios := []struct {
name string
cfg TLSConfig
expectedConfig tls.RenegotiationSupport
@@ -371,18 +434,19 @@ func TestTlsRenegotiation(t *testing.T) {
expectedConfig: tls.RenegotiateNever,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
for _, scenario := range scenarios {
t.Run(scenario.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)
tlsConfig := configureTLS(tls, scenario.cfg)
if tlsConfig.Renegotiation != scenario.expectedConfig {
t.Errorf("expected tls renegotiation to be %v, but got %v", scenario.expectedConfig, tls.Renegotiation)
}
})
}
}
func TestQueryDNS(t *testing.T) {
t.Parallel()
scenarios := []struct {
name string
inputDNS dns.Config
@@ -439,7 +503,7 @@ func TestQueryDNS(t *testing.T) {
},
inputURL: "8.8.8.8",
expectedDNSCode: "NOERROR",
expectedBody: "*.iana-servers.net.",
expectedBody: "*.ns.cloudflare.com.",
},
{
name: "test Config with type PTR",
@@ -461,6 +525,16 @@ func TestQueryDNS(t *testing.T) {
expectedDNSCode: "NOERROR",
expectedBody: "one.one.one.one.",
},
{
name: "test Config with type TXT",
inputDNS: dns.Config{
QueryType: "TXT",
QueryName: "example.com.",
},
inputURL: "1.1.1.1",
expectedDNSCode: "NOERROR",
expectedBody: "*v=spf1*",
},
{
name: "test Config with fake type and retrieve error",
inputDNS: dns.Config{
@@ -480,8 +554,8 @@ func TestQueryDNS(t *testing.T) {
if dnsRCode != scenario.expectedDNSCode {
t.Errorf("expected DNSRCode to be %s, got %s", scenario.expectedDNSCode, dnsRCode)
}
if scenario.inputDNS.QueryType == "NS" {
// Because there are often multiple nameservers backing a single domain, we'll only look at the suffix
if scenario.inputDNS.QueryType == "NS" || scenario.inputDNS.QueryType == "TXT" {
// Some record types can have multiple valid answers, so wildcard matching is used in those scenarios
if !pattern.Match(scenario.expectedBody, string(body)) {
t.Errorf("got %s, expected result %s,", string(body), scenario.expectedBody)
}
@@ -512,15 +586,13 @@ func TestQueryDNS(t *testing.T) {
}
func TestCheckSSHBanner(t *testing.T) {
t.Parallel()
cfg := &Config{Timeout: 3}
t.Run("no-auth-ssh", func(t *testing.T) {
connected, status, err := CheckSSHBanner("tty.sdf.org", cfg)
if err != nil {
t.Errorf("Expected: error != nil, got: %v ", err)
}
if connected == false {
t.Errorf("Expected: connected == true, got: %v", connected)
}
@@ -528,14 +600,11 @@ func TestCheckSSHBanner(t *testing.T) {
t.Errorf("Expected: 0, got: %v", status)
}
})
t.Run("invalid-address", func(t *testing.T) {
connected, status, err := CheckSSHBanner("idontplaytheodds.com", cfg)
if err == nil {
t.Errorf("Expected: error, got: %v ", err)
}
if connected != false {
t.Errorf("Expected: connected == false, got: %v", connected)
}
@@ -543,5 +612,4 @@ func TestCheckSSHBanner(t *testing.T) {
t.Errorf("Expected: 1, got: %v", status)
}
})
}
+29 -2
View File
@@ -6,11 +6,13 @@ import (
"errors"
"net"
"net/http"
"net/http/cookiejar"
"net/url"
"regexp"
"strconv"
"time"
"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel"
"github.com/TwiN/logr"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
@@ -33,6 +35,7 @@ var (
IgnoreRedirect: false,
Timeout: defaultTimeout,
Network: "ip",
StoreCookies: false,
}
)
@@ -69,13 +72,22 @@ type Config struct {
// 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"`
// Tunnel is the name of the SSH tunnel to use for the client
Tunnel string `yaml:"tunnel,omitempty"`
// ResolvedTunnel is the resolved SSH tunnel for this specific Config
ResolvedTunnel *sshtunnel.SSHTunnel `yaml:"-"`
// StoreCookies determines whether cookies are stored and included across requests.
StoreCookies bool `yaml:"store-cookies,omitempty"`
httpClient *http.Client
}
// DNSResolverConfig is the parsed configuration from the DNSResolver config string.
@@ -265,6 +277,21 @@ func (c *Config) getHTTPClient() *http.Client {
} else if c.HasIAPConfig() {
c.httpClient = configureIAP(c.httpClient, *c.IAPConfig)
}
if c.ResolvedTunnel != nil {
// Use SSH tunnel dialer
if transport, ok := c.httpClient.Transport.(*http.Transport); ok {
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return c.ResolvedTunnel.Dial(network, addr)
}
}
}
if c.StoreCookies {
jar, err := cookiejar.New(nil)
if err != nil {
logr.Fatalf("[client.getHTTPClient] Failed to initialize cookie jar: %v", err)
}
c.httpClient.Jar = jar
}
}
return c.httpClient
}
+15
View File
@@ -17,6 +17,9 @@ func TestConfig_getHTTPClient(t *testing.T) {
if insecureClient.Timeout != defaultTimeout {
t.Error("expected Config.Timeout to default the HTTP client to a timeout of 10s")
}
if insecureClient.Jar != nil {
t.Error("expected Config.StoreCookies to default the HTTP client to not store cookies")
}
request, _ := http.NewRequest("GET", "", nil)
if err := insecureClient.CheckRedirect(request, nil); err != nil {
t.Error("expected Config.IgnoreRedirect set to false to cause the HTTP client's CheckRedirect to return nil")
@@ -31,12 +34,24 @@ func TestConfig_getHTTPClient(t *testing.T) {
if secureClient.Timeout != 5*time.Second {
t.Error("expected Config.Timeout to cause the HTTP client to have a timeout of 5s")
}
if insecureClient.Jar != nil {
t.Error("expected Config.StoreCookies to default the HTTP client to not store cookies")
}
request, _ = http.NewRequest("GET", "", nil)
if err := secureClient.CheckRedirect(request, nil); err != http.ErrUseLastResponse {
t.Error("expected Config.IgnoreRedirect set to true to cause the HTTP client's CheckRedirect to return http.ErrUseLastResponse")
}
}
func TestConfig_getHTTPClient_withCookieJar(t *testing.T) {
config := &Config{StoreCookies: true}
config.ValidateAndSetDefaults()
client := config.getHTTPClient()
if client.Jar == nil {
t.Error("expected Config.StoreCookies to cause the HTTP client to have a CookieJar")
}
}
func TestConfig_ValidateAndSetDefaults_withCustomDNSResolver(t *testing.T) {
type args struct {
dnsResolver string
+71
View File
@@ -0,0 +1,71 @@
package client
import (
"context"
"crypto/tls"
"net"
"time"
"github.com/TwiN/logr"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
health "google.golang.org/grpc/health/grpc_health_v1"
)
// PerformGRPCHealthCheck dials a gRPC target and performs the standard Health/Check RPC.
// Returns whether a connection was established, the serving status string, an error (if any), and the elapsed duration.
func PerformGRPCHealthCheck(address string, useTLS bool, cfg *Config) (bool, string, error, time.Duration) {
if cfg == nil {
cfg = GetDefaultConfig()
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout)
defer cancel()
var opts []grpc.DialOption
// Transport credentials
if useTLS {
tlsCfg := &tls.Config{InsecureSkipVerify: cfg.Insecure}
if cfg.HasTLSConfig() && cfg.TLS.isValid() == nil {
tlsCfg = configureTLS(tlsCfg, *cfg.TLS)
}
opts = append(opts, grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)))
} else {
opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
}
// Custom dialer for DNS resolver or SSH tunnel
opts = append(opts, grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
if cfg.ResolvedTunnel != nil {
return cfg.ResolvedTunnel.Dial("tcp", addr)
}
if cfg.HasCustomDNSResolver() {
resolverCfg, err := cfg.parseDNSResolver()
if err != nil {
// Shouldn't happen because already validated; log and fall back
logr.Errorf("[client.PerformGRPCHealthCheck] invalid DNS resolver: %v", err)
} else {
d := &net.Dialer{Resolver: &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
d := net.Dialer{}
return d.DialContext(ctx, resolverCfg.Protocol, resolverCfg.Host+":"+resolverCfg.Port)
}}}
return d.DialContext(ctx, "tcp", addr)
}
}
var d net.Dialer
return d.DialContext(ctx, "tcp", addr)
}))
start := time.Now()
conn, err := grpc.DialContext(ctx, address, opts...)
if err != nil {
return false, "", err, time.Since(start)
}
defer conn.Close()
client := health.NewHealthClient(conn)
resp, err := client.Check(ctx, &health.HealthCheckRequest{Service: ""})
if err != nil {
return false, "", err, time.Since(start)
}
return true, resp.GetStatus().String(), nil, time.Since(start)
}
+4
View File
@@ -53,6 +53,10 @@ type Announcement struct {
// Message is the user-facing text describing the announcement
Message string `yaml:"message" json:"message"`
// Archived indicates whether the announcement should be displayed in the historical section
// instead of at the top of the status page
Archived bool `yaml:"archived,omitempty" json:"archived,omitempty"`
}
// ValidateAndSetDefaults validates the announcement and sets default values if necessary
+241
View File
@@ -0,0 +1,241 @@
package announcement
import (
"errors"
"testing"
"time"
)
func TestAnnouncement_ValidateAndSetDefaults(t *testing.T) {
now := time.Now()
scenarios := []struct {
name string
announcement *Announcement
expectedError error
expectedType string
}{
{
name: "valid-announcement-with-all-fields",
announcement: &Announcement{
Timestamp: now,
Type: TypeWarning,
Message: "This is a test announcement",
Archived: false,
},
expectedError: nil,
expectedType: TypeWarning,
},
{
name: "valid-announcement-with-archived-true",
announcement: &Announcement{
Timestamp: now,
Type: TypeOperational,
Message: "This is an archived announcement",
Archived: true,
},
expectedError: nil,
expectedType: TypeOperational,
},
{
name: "valid-announcement-with-empty-type-should-default-to-none",
announcement: &Announcement{
Timestamp: now,
Message: "This announcement has no type",
},
expectedError: nil,
expectedType: TypeNone,
},
{
name: "invalid-announcement-with-empty-message",
announcement: &Announcement{
Timestamp: now,
Type: TypeWarning,
Message: "",
},
expectedError: ErrEmptyMessage,
},
{
name: "invalid-announcement-with-zero-timestamp",
announcement: &Announcement{
Timestamp: time.Time{},
Type: TypeWarning,
Message: "Test message",
},
expectedError: ErrMissingTimestamp,
},
{
name: "invalid-announcement-with-invalid-type",
announcement: &Announcement{
Timestamp: now,
Type: "invalid-type",
Message: "Test message",
},
expectedError: ErrInvalidAnnouncementType,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.announcement.ValidateAndSetDefaults()
if !errors.Is(err, scenario.expectedError) {
t.Errorf("expected error %v, got %v", scenario.expectedError, err)
}
if scenario.expectedError == nil && scenario.announcement.Type != scenario.expectedType {
t.Errorf("expected type %s, got %s", scenario.expectedType, scenario.announcement.Type)
}
})
}
}
func TestAnnouncement_ValidateAndSetDefaults_AllTypes(t *testing.T) {
now := time.Now()
validTypes := []string{TypeOutage, TypeWarning, TypeInformation, TypeOperational, TypeNone}
for _, validType := range validTypes {
t.Run("type-"+validType, func(t *testing.T) {
announcement := &Announcement{
Timestamp: now,
Type: validType,
Message: "Test message",
}
if err := announcement.ValidateAndSetDefaults(); err != nil {
t.Errorf("expected no error for type %s, got %v", validType, err)
}
if announcement.Type != validType {
t.Errorf("expected type %s, got %s", validType, announcement.Type)
}
})
}
}
func TestSortByTimestamp(t *testing.T) {
now := time.Now()
earlier := now.Add(-1 * time.Hour)
later := now.Add(1 * time.Hour)
announcements := []*Announcement{
{Timestamp: now, Message: "now"},
{Timestamp: later, Message: "later"},
{Timestamp: earlier, Message: "earlier"},
}
SortByTimestamp(announcements)
if announcements[0].Timestamp != later {
t.Error("expected first announcement to be the latest")
}
if announcements[1].Timestamp != now {
t.Error("expected second announcement to be the middle one")
}
if announcements[2].Timestamp != earlier {
t.Error("expected third announcement to be the earliest")
}
}
func TestSortByTimestamp_WithArchivedField(t *testing.T) {
now := time.Now()
earlier := now.Add(-1 * time.Hour)
later := now.Add(1 * time.Hour)
announcements := []*Announcement{
{Timestamp: now, Message: "now", Archived: false},
{Timestamp: later, Message: "later", Archived: true},
{Timestamp: earlier, Message: "earlier", Archived: false},
}
SortByTimestamp(announcements)
// Sorting should be by timestamp only, not affected by archived status
if announcements[0].Timestamp != later {
t.Error("expected first announcement to be the latest, regardless of archived status")
}
if !announcements[0].Archived {
t.Error("expected first announcement to be archived")
}
if announcements[1].Timestamp != now {
t.Error("expected second announcement to be the middle one")
}
if announcements[2].Timestamp != earlier {
t.Error("expected third announcement to be the earliest")
}
}
func TestValidateAndSetDefaults_Slice(t *testing.T) {
now := time.Now()
scenarios := []struct {
name string
announcements []*Announcement
expectedError error
shouldValidate bool
}{
{
name: "all-valid-announcements",
announcements: []*Announcement{
{Timestamp: now, Type: TypeWarning, Message: "First announcement"},
{Timestamp: now, Type: TypeOperational, Message: "Second announcement"},
},
expectedError: nil,
shouldValidate: true,
},
{
name: "mixed-archived-announcements",
announcements: []*Announcement{
{Timestamp: now, Type: TypeWarning, Message: "Active announcement", Archived: false},
{Timestamp: now, Type: TypeOperational, Message: "Archived announcement", Archived: true},
},
expectedError: nil,
shouldValidate: true,
},
{
name: "one-invalid-announcement-in-slice",
announcements: []*Announcement{
{Timestamp: now, Type: TypeWarning, Message: "Valid announcement"},
{Timestamp: now, Type: TypeOperational, Message: ""},
},
expectedError: ErrEmptyMessage,
shouldValidate: false,
},
{
name: "announcement-with-missing-timestamp",
announcements: []*Announcement{
{Timestamp: now, Type: TypeWarning, Message: "Valid announcement"},
{Timestamp: time.Time{}, Type: TypeOperational, Message: "Invalid announcement"},
},
expectedError: ErrMissingTimestamp,
shouldValidate: false,
},
{
name: "announcement-with-invalid-type",
announcements: []*Announcement{
{Timestamp: now, Type: TypeWarning, Message: "Valid announcement"},
{Timestamp: now, Type: "bad-type", Message: "Invalid announcement"},
},
expectedError: ErrInvalidAnnouncementType,
shouldValidate: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := ValidateAndSetDefaults(scenario.announcements)
if !errors.Is(err, scenario.expectedError) {
t.Errorf("expected error %v, got %v", scenario.expectedError, err)
}
})
}
}
func TestAnnouncement_ArchivedFieldDefaults(t *testing.T) {
now := time.Now()
announcement := &Announcement{
Timestamp: now,
Type: TypeWarning,
Message: "Test announcement",
// Archived not set, should default to false
}
if err := announcement.ValidateAndSetDefaults(); err != nil {
t.Errorf("expected no error, got %v", err)
}
// Zero value for bool is false
if announcement.Archived {
t.Error("expected Archived to default to false")
}
}
func TestValidateAndSetDefaults_EmptySlice(t *testing.T) {
announcements := []*Announcement{}
if err := ValidateAndSetDefaults(announcements); err != nil {
t.Errorf("expected no error for empty slice, got %v", err)
}
}
+110 -50
View File
@@ -6,6 +6,7 @@ import (
"io/fs"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"time"
@@ -14,6 +15,7 @@ import (
"github.com/TwiN/gatus/v5/alerting"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/alerting/provider"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/announcement"
"github.com/TwiN/gatus/v5/config/connectivity"
"github.com/TwiN/gatus/v5/config/endpoint"
@@ -21,6 +23,7 @@ import (
"github.com/TwiN/gatus/v5/config/maintenance"
"github.com/TwiN/gatus/v5/config/remote"
"github.com/TwiN/gatus/v5/config/suite"
"github.com/TwiN/gatus/v5/config/tunneling"
"github.com/TwiN/gatus/v5/config/ui"
"github.com/TwiN/gatus/v5/config/web"
"github.com/TwiN/gatus/v5/security"
@@ -114,6 +117,9 @@ type Config struct {
// Connectivity is the configuration for connectivity
Connectivity *connectivity.Config `yaml:"connectivity,omitempty"`
// Tunneling is the configuration for SSH tunneling
Tunneling *tunneling.Config `yaml:"tunneling,omitempty"`
// Announcements is the list of system-wide announcements
Announcements []*announcement.Announcement `yaml:"announcements,omitempty"`
@@ -131,7 +137,7 @@ func (config *Config) GetUniqueExtraMetricLabels() []string {
continue
}
for label := range ep.ExtraLabels {
if contains(labels, label) {
if slices.Contains(labels, label) {
continue
}
labels = append(labels, label)
@@ -295,55 +301,111 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
logr.Warn("WARNING: Please use the GATUS_LOG_LEVEL environment variable instead")
}
// XXX: End of v6.0.0 removals
validateAlertingConfig(config.Alerting, config.Endpoints, config.ExternalEndpoints)
if err := validateSecurityConfig(config); err != nil {
ValidateAlertingConfig(config.Alerting, config.Endpoints, config.ExternalEndpoints)
if err := ValidateSecurityConfig(config); err != nil {
return nil, err
}
if err := validateEndpointsConfig(config); err != nil {
if err := ValidateEndpointsConfig(config); err != nil {
return nil, err
}
if err := validateWebConfig(config); err != nil {
if err := ValidateWebConfig(config); err != nil {
return nil, err
}
if err := validateUIConfig(config); err != nil {
if err := ValidateUIConfig(config); err != nil {
return nil, err
}
if err := validateMaintenanceConfig(config); err != nil {
if err := ValidateMaintenanceConfig(config); err != nil {
return nil, err
}
if err := validateStorageConfig(config); err != nil {
if err := ValidateStorageConfig(config); err != nil {
return nil, err
}
if err := validateRemoteConfig(config); err != nil {
if err := ValidateRemoteConfig(config); err != nil {
return nil, err
}
if err := validateConnectivityConfig(config); err != nil {
if err := ValidateConnectivityConfig(config); err != nil {
return nil, err
}
if err := validateAnnouncementsConfig(config); err != nil {
if err := ValidateTunnelingConfig(config); err != nil {
return nil, err
}
if err := validateSuitesConfig(config); err != nil {
if err := ValidateAnnouncementsConfig(config); err != nil {
return nil, err
}
if err := validateUniqueKeys(config); err != nil {
if err := ValidateSuitesConfig(config); err != nil {
return nil, err
}
validateAndSetConcurrencyDefaults(config)
if err := ValidateUniqueKeys(config); err != nil {
return nil, err
}
ValidateAndSetConcurrencyDefaults(config)
// Cross-config changes
config.UI.MaximumNumberOfResults = config.Storage.MaximumNumberOfResults
}
return
}
func validateConnectivityConfig(config *Config) error {
func ValidateConnectivityConfig(config *Config) error {
if config.Connectivity != nil {
return config.Connectivity.ValidateAndSetDefaults()
}
return nil
}
func validateAnnouncementsConfig(config *Config) error {
// ValidateTunnelingConfig validates the tunneling configuration and resolves tunnel references
// NOTE: This must be called after ValidateEndpointsConfig and ValidateSuitesConfig
// because it resolves tunnel references in endpoint and suite client configurations
func ValidateTunnelingConfig(config *Config) error {
if config.Tunneling != nil {
if err := config.Tunneling.ValidateAndSetDefaults(); err != nil {
return err
}
// Resolve tunnel references in all endpoints
for _, ep := range config.Endpoints {
if err := resolveTunnelForClientConfig(config, ep.ClientConfig); err != nil {
return fmt.Errorf("endpoint '%s': %w", ep.Key(), err)
}
}
// Resolve tunnel references in suite endpoints
for _, s := range config.Suites {
for _, ep := range s.Endpoints {
if err := resolveTunnelForClientConfig(config, ep.ClientConfig); err != nil {
return fmt.Errorf("suite '%s' endpoint '%s': %w", s.Key(), ep.Key(), err)
}
}
}
// TODO: Add tunnel support for alert providers when needed
}
return nil
}
// resolveTunnelForClientConfig resolves tunnel references in a client configuration
func resolveTunnelForClientConfig(config *Config, clientConfig *client.Config) error {
if clientConfig == nil || clientConfig.Tunnel == "" {
return nil
}
// Validate tunnel name
tunnelName := strings.TrimSpace(clientConfig.Tunnel)
if tunnelName == "" {
return fmt.Errorf("tunnel name cannot be empty")
}
if config.Tunneling == nil {
return fmt.Errorf("tunnel '%s' referenced but no tunneling configuration defined", tunnelName)
}
_, exists := config.Tunneling.Tunnels[tunnelName]
if !exists {
return fmt.Errorf("tunnel '%s' not found in tunneling configuration", tunnelName)
}
// Get or create the SSH tunnel instance and store it directly in client config
tunnel, err := config.Tunneling.GetTunnel(tunnelName)
if err != nil {
return fmt.Errorf("failed to get tunnel '%s': %w", tunnelName, err)
}
clientConfig.ResolvedTunnel = tunnel
return nil
}
func ValidateAnnouncementsConfig(config *Config) error {
if config.Announcements != nil {
if err := announcement.ValidateAndSetDefaults(config.Announcements); err != nil {
return err
@@ -354,7 +416,7 @@ func validateAnnouncementsConfig(config *Config) error {
return nil
}
func validateRemoteConfig(config *Config) error {
func ValidateRemoteConfig(config *Config) error {
if config.Remote != nil {
if err := config.Remote.ValidateAndSetDefaults(); err != nil {
return err
@@ -363,7 +425,7 @@ func validateRemoteConfig(config *Config) error {
return nil
}
func validateStorageConfig(config *Config) error {
func ValidateStorageConfig(config *Config) error {
if config.Storage == nil {
config.Storage = &storage.Config{
Type: storage.TypeMemory,
@@ -378,7 +440,7 @@ func validateStorageConfig(config *Config) error {
return nil
}
func validateMaintenanceConfig(config *Config) error {
func ValidateMaintenanceConfig(config *Config) error {
if config.Maintenance == nil {
config.Maintenance = maintenance.GetDefaultConfig()
} else {
@@ -389,7 +451,7 @@ func validateMaintenanceConfig(config *Config) error {
return nil
}
func validateUIConfig(config *Config) error {
func ValidateUIConfig(config *Config) error {
if config.UI == nil {
config.UI = ui.GetDefaultConfig()
} else {
@@ -400,7 +462,7 @@ func validateUIConfig(config *Config) error {
return nil
}
func validateWebConfig(config *Config) error {
func ValidateWebConfig(config *Config) error {
if config.Web == nil {
config.Web = web.GetDefaultConfig()
} else {
@@ -409,11 +471,11 @@ func validateWebConfig(config *Config) error {
return nil
}
func validateEndpointsConfig(config *Config) error {
func ValidateEndpointsConfig(config *Config) error {
duplicateValidationMap := make(map[string]bool)
// Validate endpoints
for _, ep := range config.Endpoints {
logr.Debugf("[config.validateEndpointsConfig] Validating endpoint with key %s", ep.Key())
logr.Debugf("[config.ValidateEndpointsConfig] Validating endpoint with key %s", ep.Key())
if endpointKey := ep.Key(); duplicateValidationMap[endpointKey] {
return fmt.Errorf("invalid endpoint %s: name and group combination must be unique", ep.Key())
} else {
@@ -423,10 +485,10 @@ func validateEndpointsConfig(config *Config) error {
return fmt.Errorf("invalid endpoint %s: %w", ep.Key(), err)
}
}
logr.Infof("[config.validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
logr.Infof("[config.ValidateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
// Validate external endpoints
for _, ee := range config.ExternalEndpoints {
logr.Debugf("[config.validateEndpointsConfig] Validating external endpoint '%s'", ee.Key())
logr.Debugf("[config.ValidateEndpointsConfig] Validating external endpoint '%s'", ee.Key())
if endpointKey := ee.Key(); duplicateValidationMap[endpointKey] {
return fmt.Errorf("invalid external endpoint %s: name and group combination must be unique", ee.Key())
} else {
@@ -436,13 +498,13 @@ func validateEndpointsConfig(config *Config) error {
return fmt.Errorf("invalid external endpoint %s: %w", ee.Key(), err)
}
}
logr.Infof("[config.validateEndpointsConfig] Validated %d external endpoints", len(config.ExternalEndpoints))
logr.Infof("[config.ValidateEndpointsConfig] Validated %d external endpoints", len(config.ExternalEndpoints))
return nil
}
func validateSuitesConfig(config *Config) error {
func ValidateSuitesConfig(config *Config) error {
if config.Suites == nil || len(config.Suites) == 0 {
logr.Info("[config.validateSuitesConfig] No suites configured")
logr.Info("[config.ValidateSuitesConfig] No suites configured")
return nil
}
suiteNames := make(map[string]bool)
@@ -471,11 +533,11 @@ func validateSuitesConfig(config *Config) error {
}
}
}
logr.Infof("[config.validateSuitesConfig] Validated %d suite(s)", len(config.Suites))
logr.Infof("[config.ValidateSuitesConfig] Validated %d suite(s)", len(config.Suites))
return nil
}
func validateUniqueKeys(config *Config) error {
func ValidateUniqueKeys(config *Config) error {
keyMap := make(map[string]string) // key -> description for error messages
// Check all endpoints
for _, ep := range config.Endpoints {
@@ -512,30 +574,28 @@ func validateUniqueKeys(config *Config) error {
return nil
}
func validateSecurityConfig(config *Config) error {
func ValidateSecurityConfig(config *Config) error {
if config.Security != nil {
if config.Security.ValidateAndSetDefaults() {
logr.Debug("[config.validateSecurityConfig] Basic security configuration has been validated")
} else {
// If there was an attempt to configure security, then it must mean that some confidential or private
// data are exposed. As a result, we'll force a panic because it's better to be safe than sorry.
if !config.Security.ValidateAndSetDefaults() {
logr.Debug("[config.ValidateSecurityConfig] Basic security configuration has been validated")
return ErrInvalidSecurityConfig
}
}
return nil
}
// validateAlertingConfig validates the alerting configuration
// ValidateAlertingConfig validates the alerting configuration
// Note that the alerting configuration has to be validated before the endpoint configuration, because the default alert
// returned by provider.AlertProvider.GetDefaultAlert() must be parsed before endpoint.Endpoint.ValidateAndSetDefaults()
// sets the default alert values when none are set.
func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoint.Endpoint, externalEndpoints []*endpoint.ExternalEndpoint) {
func ValidateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoint.Endpoint, externalEndpoints []*endpoint.ExternalEndpoint) {
if alertingConfig == nil {
logr.Info("[config.validateAlertingConfig] Alerting is not configured")
logr.Info("[config.ValidateAlertingConfig] Alerting is not configured")
return
}
alertTypes := []alert.Type{
alert.TypeAWSSES,
alert.TypeClickUp,
alert.TypeCustom,
alert.TypeDatadog,
alert.TypeDiscord,
@@ -549,11 +609,11 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
alert.TypeIFTTT,
alert.TypeIlert,
alert.TypeIncidentIO,
alert.TypeJetBrainsSpace,
alert.TypeLine,
alert.TypeMatrix,
alert.TypeMattermost,
alert.TypeMessagebird,
alert.TypeN8N,
alert.TypeNewRelic,
alert.TypeNtfy,
alert.TypeOpsgenie,
@@ -586,12 +646,12 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
for _, ep := range endpoints {
for alertIndex, endpointAlert := range ep.Alerts {
if alertType == endpointAlert.Type {
logr.Debugf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ep.Key())
logr.Debugf("[config.ValidateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ep.Key())
provider.MergeProviderDefaultAlertIntoEndpointAlert(alertProvider.GetDefaultAlert(), endpointAlert)
// Validate the endpoint alert's overrides, if applicable
if len(endpointAlert.ProviderOverride) > 0 {
if err = alertProvider.ValidateOverrides(ep.Group, endpointAlert); err != nil {
logr.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ep.Key(), alertType, err.Error())
logr.Warnf("[config.ValidateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ep.Key(), alertType, err.Error())
}
}
}
@@ -600,12 +660,12 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
for _, ee := range externalEndpoints {
for alertIndex, endpointAlert := range ee.Alerts {
if alertType == endpointAlert.Type {
logr.Debugf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ee.Key())
logr.Debugf("[config.ValidateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ee.Key())
provider.MergeProviderDefaultAlertIntoEndpointAlert(alertProvider.GetDefaultAlert(), endpointAlert)
// Validate the endpoint alert's overrides, if applicable
if len(endpointAlert.ProviderOverride) > 0 {
if err = alertProvider.ValidateOverrides(ee.Group, endpointAlert); err != nil {
logr.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ee.Key(), alertType, err.Error())
logr.Warnf("[config.ValidateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ee.Key(), alertType, err.Error())
}
}
}
@@ -614,7 +674,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
}
validProviders = append(validProviders, alertType)
} else {
logr.Warnf("[config.validateAlertingConfig] Ignoring provider=%s due to error=%s", alertType, err.Error())
logr.Warnf("[config.ValidateAlertingConfig] Ignoring provider=%s due to error=%s", alertType, err.Error())
invalidProviders = append(invalidProviders, alertType)
alertingConfig.SetAlertingProviderToNil(alertProvider)
}
@@ -622,19 +682,19 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
invalidProviders = append(invalidProviders, alertType)
}
}
logr.Infof("[config.validateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
logr.Infof("[config.ValidateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
}
func validateAndSetConcurrencyDefaults(config *Config) {
func ValidateAndSetConcurrencyDefaults(config *Config) {
if config.DisableMonitoringLock {
config.Concurrency = 0
logr.Warn("WARNING: The 'disable-monitoring-lock' configuration has been deprecated and will be removed in v6.0.0")
logr.Warn("WARNING: Please set 'concurrency: 0' instead")
logr.Debug("[config.validateAndSetConcurrencyDefaults] DisableMonitoringLock is true, setting unlimited (0) concurrency")
logr.Debug("[config.ValidateAndSetConcurrencyDefaults] DisableMonitoringLock is true, setting unlimited (0) concurrency")
} else if config.Concurrency <= 0 && !config.DisableMonitoringLock {
config.Concurrency = DefaultConcurrency
logr.Debugf("[config.validateAndSetConcurrencyDefaults] Setting default concurrency to %d", config.Concurrency)
logr.Debugf("[config.ValidateAndSetConcurrencyDefaults] Setting default concurrency to %d", config.Concurrency)
} else {
logr.Debugf("[config.validateAndSetConcurrencyDefaults] Using configured concurrency of %d", config.Concurrency)
logr.Debugf("[config.ValidateAndSetConcurrencyDefaults] Using configured concurrency of %d", config.Concurrency)
}
}
+232 -72
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"testing"
"time"
@@ -12,6 +13,7 @@ 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/clickup"
"github.com/TwiN/gatus/v5/alerting/provider/custom"
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
"github.com/TwiN/gatus/v5/alerting/provider/discord"
@@ -25,7 +27,6 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
"github.com/TwiN/gatus/v5/alerting/provider/line"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
@@ -53,6 +54,9 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/suite"
"github.com/TwiN/gatus/v5/config/tunneling"
"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel"
"github.com/TwiN/gatus/v5/config/web"
"github.com/TwiN/gatus/v5/storage"
"gopkg.in/yaml.v3"
@@ -309,20 +313,32 @@ func TestConfig_HasLoadedConfigurationBeenModified(t *testing.T) {
if config.HasLoadedConfigurationBeenModified() {
t.Errorf("expected config.HasLoadedConfigurationBeenModified() to return false because nothing has happened since it was created")
}
time.Sleep(time.Second) // Because the file mod time only has second precision, we have to wait for a second
// Update the config file
if err = os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(`endpoints:
if err = os.WriteFile(configFilePath, []byte(`endpoints:
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"`), 0o644); err != nil {
t.Fatalf("failed to overwrite config file: %v", err)
}
// File mod times have second precision, so advance it explicitly.
future := time.Now().Add(2 * time.Second)
if err = os.Chtimes(configFilePath, future, future); err != nil {
t.Fatalf("failed to advance config file mod time: %v", err)
}
if !config.HasLoadedConfigurationBeenModified() {
t.Errorf("expected config.HasLoadedConfigurationBeenModified() to return true because a new file has been added in the directory")
t.Errorf("expected config.HasLoadedConfigurationBeenModified() to return true because the config file has been modified")
}
})
t.Run("config-directory-as-config-path", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(`endpoints:
- name: website
url: https://twin.sh/health
conditions:
- "[STATUS] == 200"
`), 0o644); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
config, err := LoadConfiguration(dir)
if err != nil {
t.Fatalf("failed to load configuration: %v", err)
@@ -330,10 +346,13 @@ func TestConfig_HasLoadedConfigurationBeenModified(t *testing.T) {
if config.HasLoadedConfigurationBeenModified() {
t.Errorf("expected config.HasLoadedConfigurationBeenModified() to return false because nothing has happened since it was created")
}
time.Sleep(time.Second) // Because the file mod time only has second precision, we have to wait for a second
// Update the config file
if err = os.WriteFile(filepath.Join(dir, "metrics.yaml"), []byte(`metrics: true`), 0o644); err != nil {
t.Fatalf("failed to overwrite config file: %v", err)
t.Fatalf("failed to add config file: %v", err)
}
// File mod times have second precision, so advance it explicitly.
future := time.Now().Add(2 * time.Second)
if err = os.Chtimes(filepath.Join(dir, "metrics.yaml"), future, future); err != nil {
t.Fatalf("failed to advance config file mod time: %v", err)
}
if !config.HasLoadedConfigurationBeenModified() {
t.Errorf("expected config.HasLoadedConfigurationBeenModified() to return true because a new file has been added in the directory")
@@ -772,10 +791,6 @@ alerting:
to: "+1-234-567-8901"
teams:
webhook-url: "http://example.com"
jetbrainsspace:
project: "foo"
channel-id: "bar"
token: "baz"
endpoints:
- name: website
@@ -798,7 +813,6 @@ endpoints:
success-threshold: 15
- type: teams
- type: pushover
- type: jetbrainsspace
conditions:
- "[STATUS] == 200"
`))
@@ -825,8 +839,8 @@ endpoints:
if config.Endpoints[0].Interval != 60*time.Second {
t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second)
}
if len(config.Endpoints[0].Alerts) != 10 {
t.Fatal("There should've been 10 alerts configured")
if len(config.Endpoints[0].Alerts) != 9 {
t.Fatal("There should've been 9 alerts configured")
}
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
@@ -933,12 +947,6 @@ 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) {
@@ -998,14 +1006,6 @@ 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
email:
from: "from@example.com"
username: "from@example.com"
@@ -1044,7 +1044,6 @@ endpoints:
- type: twilio
- type: teams
- type: pushover
- type: jetbrainsspace
- type: email
- type: gotify
conditions:
@@ -1166,22 +1165,6 @@ endpoints:
t.Fatal("Teams.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.JetBrainsSpace == nil || config.Alerting.JetBrainsSpace.Validate() != nil {
t.Fatal("JetBrainsSpace alerting config should've been valid")
}
if config.Alerting.JetBrainsSpace.GetDefaultAlert() == nil {
t.Fatal("JetBrainsSpace.GetDefaultAlert() shouldn't have returned nil")
}
if config.Alerting.JetBrainsSpace.DefaultConfig.Project != "foo" {
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "foo", config.Alerting.JetBrainsSpace.DefaultConfig.Project)
}
if config.Alerting.JetBrainsSpace.DefaultConfig.ChannelID != "bar" {
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "bar", config.Alerting.JetBrainsSpace.DefaultConfig.ChannelID)
}
if config.Alerting.JetBrainsSpace.DefaultConfig.Token != "baz" {
t.Errorf("JetBrainsSpace webhook should've been %s, but was %s", "baz", config.Alerting.JetBrainsSpace.DefaultConfig.Token)
}
if config.Alerting.Email == nil || config.Alerting.Email.Validate() != nil {
t.Fatal("Email alerting config should've been valid")
}
@@ -1253,8 +1236,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) != 12 {
t.Fatalf("There should've been 12 alerts configured, got %d", len(config.Endpoints[0].Alerts))
if len(config.Endpoints[0].Alerts) != 11 {
t.Fatalf("There should've been 11 alerts configured, got %d", len(config.Endpoints[0].Alerts))
}
if config.Endpoints[0].Alerts[0].Type != alert.TypeSlack {
@@ -1371,21 +1354,21 @@ 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].Type != alert.TypeEmail {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeEmail, 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", 5, config.Endpoints[0].Alerts[9].FailureThreshold)
if config.Endpoints[0].Alerts[9].FailureThreshold != 3 {
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", 3, config.Endpoints[0].Alerts[9].SuccessThreshold)
if config.Endpoints[0].Alerts[9].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[9].SuccessThreshold)
}
if config.Endpoints[0].Alerts[10].Type != alert.TypeEmail {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeEmail, config.Endpoints[0].Alerts[10].Type)
if config.Endpoints[0].Alerts[10].Type != alert.TypeGotify {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeGotify, config.Endpoints[0].Alerts[10].Type)
}
if !config.Endpoints[0].Alerts[10].IsEnabled() {
t.Error("The alert should've been enabled")
@@ -1396,19 +1379,6 @@ endpoints:
if config.Endpoints[0].Alerts[10].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[10].SuccessThreshold)
}
if config.Endpoints[0].Alerts[11].Type != alert.TypeGotify {
t.Errorf("The type of the alert should've been %s, but it was %s", alert.TypeGotify, config.Endpoints[0].Alerts[11].Type)
}
if !config.Endpoints[0].Alerts[11].IsEnabled() {
t.Error("The alert should've been enabled")
}
if config.Endpoints[0].Alerts[11].FailureThreshold != 3 {
t.Errorf("The default failure threshold of the alert should've been %d, but it was %d", 3, config.Endpoints[0].Alerts[11].FailureThreshold)
}
if config.Endpoints[0].Alerts[11].SuccessThreshold != 2 {
t.Errorf("The default success threshold of the alert should've been %d, but it was %d", 2, config.Endpoints[0].Alerts[11].SuccessThreshold)
}
}
func TestParseAndValidateConfigBytesWithAlertingAndDefaultAlertAndMultipleAlertsOfSameTypeWithOverriddenParameters(t *testing.T) {
@@ -1901,6 +1871,7 @@ func TestParseAndValidateConfigBytesWithNoEndpoints(t *testing.T) {
func TestGetAlertingProviderByAlertType(t *testing.T) {
alertingConfig := &alerting.Config{
AWSSimpleEmailService: &awsses.AlertProvider{},
ClickUp: &clickup.AlertProvider{},
Custom: &custom.AlertProvider{},
Datadog: &datadog.AlertProvider{},
Discord: &discord.AlertProvider{},
@@ -1914,7 +1885,6 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
IFTTT: &ifttt.AlertProvider{},
Ilert: &ilert.AlertProvider{},
IncidentIO: &incidentio.AlertProvider{},
JetBrainsSpace: &jetbrainsspace.AlertProvider{},
Line: &line.AlertProvider{},
Matrix: &matrix.AlertProvider{},
Mattermost: &mattermost.AlertProvider{},
@@ -1946,6 +1916,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
expected provider.AlertProvider
}{
{alertType: alert.TypeAWSSES, expected: alertingConfig.AWSSimpleEmailService},
{alertType: alert.TypeClickUp, expected: alertingConfig.ClickUp},
{alertType: alert.TypeCustom, expected: alertingConfig.Custom},
{alertType: alert.TypeDatadog, expected: alertingConfig.Datadog},
{alertType: alert.TypeDiscord, expected: alertingConfig.Discord},
@@ -1959,7 +1930,6 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
{alertType: alert.TypeIFTTT, expected: alertingConfig.IFTTT},
{alertType: alert.TypeIlert, expected: alertingConfig.Ilert},
{alertType: alert.TypeIncidentIO, expected: alertingConfig.IncidentIO},
{alertType: alert.TypeJetBrainsSpace, expected: alertingConfig.JetBrainsSpace},
{alertType: alert.TypeLine, expected: alertingConfig.Line},
{alertType: alert.TypeMatrix, expected: alertingConfig.Matrix},
{alertType: alert.TypeMattermost, expected: alertingConfig.Mattermost},
@@ -2098,7 +2068,7 @@ func TestConfig_GetUniqueExtraMetricLabels(t *testing.T) {
t.Errorf("expected %d labels, got %d", len(tt.expected), len(labels))
}
for _, label := range tt.expected {
if !contains(labels, label) {
if !slices.Contains(labels, label) {
t.Errorf("expected label %s to be present", label)
}
}
@@ -2484,3 +2454,193 @@ suites:
})
}
}
func TestValidateTunnelingConfig(t *testing.T) {
tests := []struct {
name string
config *Config
wantErr bool
errMsg string
}{
{
name: "valid tunneling config",
config: &Config{
Tunneling: &tunneling.Config{
Tunnels: map[string]*sshtunnel.Config{
"test": {
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
},
},
},
Endpoints: []*endpoint.Endpoint{
{
Name: "test-endpoint",
URL: "http://example.com/health",
ClientConfig: &client.Config{
Tunnel: "test",
},
Conditions: []endpoint.Condition{"[STATUS] == 200"},
},
},
},
wantErr: false,
},
{
name: "invalid tunnel reference in endpoint",
config: &Config{
Tunneling: &tunneling.Config{
Tunnels: map[string]*sshtunnel.Config{
"test": {
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
},
},
},
Endpoints: []*endpoint.Endpoint{
{
Name: "test-endpoint",
URL: "http://example.com/health",
ClientConfig: &client.Config{
Tunnel: "nonexistent",
},
Conditions: []endpoint.Condition{"[STATUS] == 200"},
},
},
},
wantErr: true,
errMsg: "endpoint '_test-endpoint': tunnel 'nonexistent' not found in tunneling configuration",
},
{
name: "invalid tunnel reference in suite endpoint",
config: &Config{
Tunneling: &tunneling.Config{
Tunnels: map[string]*sshtunnel.Config{
"test": {
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
},
},
},
Suites: []*suite.Suite{
{
Name: "test-suite",
Endpoints: []*endpoint.Endpoint{
{
Name: "suite-endpoint",
URL: "http://example.com/health",
ClientConfig: &client.Config{
Tunnel: "invalid",
},
Conditions: []endpoint.Condition{"[STATUS] == 200"},
},
},
},
},
},
wantErr: true,
errMsg: "suite '_test-suite' endpoint '_suite-endpoint': tunnel 'invalid' not found in tunneling configuration",
},
{
name: "no tunneling config",
config: &Config{
Endpoints: []*endpoint.Endpoint{
{
Name: "test-endpoint",
URL: "http://example.com/health",
Conditions: []endpoint.Condition{"[STATUS] == 200"},
},
},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateTunnelingConfig(tt.config)
if tt.wantErr {
if err == nil {
t.Error("ValidateTunnelingConfig() expected error but got none")
return
}
if err.Error() != tt.errMsg {
t.Errorf("ValidateTunnelingConfig() error = %v, want %v", err.Error(), tt.errMsg)
}
return
}
if err != nil {
t.Errorf("ValidateTunnelingConfig() unexpected error = %v", err)
}
})
}
}
func TestResolveTunnelForClientConfig(t *testing.T) {
config := &Config{
Tunneling: &tunneling.Config{
Tunnels: map[string]*sshtunnel.Config{
"test": {
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
},
},
},
}
err := config.Tunneling.ValidateAndSetDefaults()
if err != nil {
t.Fatalf("Failed to validate tunnel config: %v", err)
}
tests := []struct {
name string
clientConfig *client.Config
wantErr bool
errMsg string
}{
{
name: "valid tunnel reference",
clientConfig: &client.Config{
Tunnel: "test",
},
wantErr: false,
},
{
name: "invalid tunnel reference",
clientConfig: &client.Config{
Tunnel: "nonexistent",
},
wantErr: true,
errMsg: "tunnel 'nonexistent' not found in tunneling configuration",
},
{
name: "no tunnel reference",
clientConfig: &client.Config{},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := resolveTunnelForClientConfig(config, tt.clientConfig)
if tt.wantErr {
if err == nil {
t.Error("resolveTunnelForClientConfig() expected error but got none")
return
}
if err.Error() != tt.errMsg {
t.Errorf("resolveTunnelForClientConfig() error = %v, want %v", err.Error(), tt.errMsg)
}
return
}
if err != nil {
t.Errorf("resolveTunnelForClientConfig() unexpected error = %v", err)
}
})
}
}
+52 -9
View File
@@ -26,7 +26,7 @@ type Condition string
// Validate checks if the Condition is valid
func (c Condition) Validate() error {
r := &Result{}
c.evaluate(r, false, nil)
c.evaluate(r, false, false, nil)
if len(r.Errors) != 0 {
return errors.New(r.Errors[0])
}
@@ -34,44 +34,50 @@ func (c Condition) Validate() error {
}
// evaluate the Condition with the Result and an optional context
func (c Condition) evaluate(result *Result, dontResolveFailedConditions bool, context *gontext.Gontext) bool {
func (c Condition) evaluate(result *Result, dontResolveFailedConditions bool, resolveSuccessfulConditions bool, context *gontext.Gontext) bool {
condition := string(c)
success := false
conditionToDisplay := condition
shouldResolveCondition := func(success bool) bool {
if success {
return resolveSuccessfulConditions
}
return !dontResolveFailedConditions
}
if strings.Contains(condition, " == ") {
parameters, resolvedParameters := sanitizeAndResolveWithContext(strings.Split(condition, " == "), result, context)
success = isEqual(resolvedParameters[0], resolvedParameters[1])
if !success && !dontResolveFailedConditions {
if shouldResolveCondition(success) {
conditionToDisplay = prettify(parameters, resolvedParameters, "==")
}
} else if strings.Contains(condition, " != ") {
parameters, resolvedParameters := sanitizeAndResolveWithContext(strings.Split(condition, " != "), result, context)
success = !isEqual(resolvedParameters[0], resolvedParameters[1])
if !success && !dontResolveFailedConditions {
if shouldResolveCondition(success) {
conditionToDisplay = prettify(parameters, resolvedParameters, "!=")
}
} else if strings.Contains(condition, " <= ") {
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " <= "), result, context)
success = resolvedParameters[0] <= resolvedParameters[1]
if !success && !dontResolveFailedConditions {
if shouldResolveCondition(success) {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<=")
}
} else if strings.Contains(condition, " >= ") {
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " >= "), result, context)
success = resolvedParameters[0] >= resolvedParameters[1]
if !success && !dontResolveFailedConditions {
if shouldResolveCondition(success) {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">=")
}
} else if strings.Contains(condition, " > ") {
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " > "), result, context)
success = resolvedParameters[0] > resolvedParameters[1]
if !success && !dontResolveFailedConditions {
if shouldResolveCondition(success) {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">")
}
} else if strings.Contains(condition, " < ") {
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " < "), result, context)
success = resolvedParameters[0] < resolvedParameters[1]
if !success && !dontResolveFailedConditions {
if shouldResolveCondition(success) {
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<")
}
} else {
@@ -209,7 +215,44 @@ func sanitizeAndResolveNumericalWithContext(list []string, result *Result, conte
}
func prettifyNumericalParameters(parameters []string, resolvedParameters []int64, operator string) string {
return prettify(parameters, []string{strconv.Itoa(int(resolvedParameters[0])), strconv.Itoa(int(resolvedParameters[1]))}, operator)
resolvedStrings := make([]string, 2)
for i := range 2 {
// Check if the parameter is a certificate or domain expiration placeholder
if parameters[i] == CertificateExpirationPlaceholder || parameters[i] == DomainExpirationPlaceholder {
// Format as duration string (convert milliseconds back to duration)
duration := time.Duration(resolvedParameters[i]) * time.Millisecond
resolvedStrings[i] = formatDuration(duration)
} else if _, err := time.ParseDuration(parameters[i]); err == nil {
// If the original parameter was a duration string (like "48h"), format the resolved value
// as a duration string too so it matches and doesn't show in parentheses
duration := time.Duration(resolvedParameters[i]) * time.Millisecond
resolvedStrings[i] = formatDuration(duration)
} else {
// Format as integer
resolvedStrings[i] = strconv.Itoa(int(resolvedParameters[i]))
}
}
return prettify(parameters, resolvedStrings, operator)
}
// formatDuration formats a duration in a clean, human-readable way by removing unnecessary zero components.
// For example: 336h0m0s becomes 336h, 1h30m0s becomes 1h30m, but 1h0m15s stays as 1h0m15s.
// Truncates to whole seconds to avoid decimal values like 7353h5m54.67s.
func formatDuration(d time.Duration) string {
// Truncate to whole seconds to avoid decimal seconds
d = d.Truncate(time.Second)
s := d.String()
// Special case: if duration is zero, return "0s"
if s == "0s" {
return "0s"
}
// Remove trailing "0s" if present
if strings.HasSuffix(s, "0s") {
s = strings.TrimSuffix(s, "0s")
// Remove trailing "0m" if present after removing "0s"
s = strings.TrimSuffix(s, "0m")
}
return s
}
// prettify returns a string representation of a condition with its parameters resolved between parentheses
+9 -9
View File
@@ -8,7 +8,7 @@ func BenchmarkCondition_evaluateWithBodyStringAny(b *testing.B) {
condition := Condition("[BODY].name == any(john.doe, jane.doe)")
for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
condition.evaluate(result, false, nil)
condition.evaluate(result, false, false, nil)
}
b.ReportAllocs()
}
@@ -17,7 +17,7 @@ func BenchmarkCondition_evaluateWithBodyStringAnyFailure(b *testing.B) {
condition := Condition("[BODY].name == any(john.doe, jane.doe)")
for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
condition.evaluate(result, false, nil)
condition.evaluate(result, false, false, nil)
}
b.ReportAllocs()
}
@@ -26,7 +26,7 @@ func BenchmarkCondition_evaluateWithBodyString(b *testing.B) {
condition := Condition("[BODY].name == john.doe")
for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
condition.evaluate(result, false, nil)
condition.evaluate(result, false, false, nil)
}
b.ReportAllocs()
}
@@ -35,7 +35,7 @@ func BenchmarkCondition_evaluateWithBodyStringFailure(b *testing.B) {
condition := Condition("[BODY].name == john.doe")
for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
condition.evaluate(result, false, nil)
condition.evaluate(result, false, false, nil)
}
b.ReportAllocs()
}
@@ -44,7 +44,7 @@ func BenchmarkCondition_evaluateWithBodyStringFailureInvalidPath(b *testing.B) {
condition := Condition("[BODY].user.name == bob.doe")
for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
condition.evaluate(result, false, nil)
condition.evaluate(result, false, false, nil)
}
b.ReportAllocs()
}
@@ -53,7 +53,7 @@ func BenchmarkCondition_evaluateWithBodyStringLen(b *testing.B) {
condition := Condition("len([BODY].name) == 8")
for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
condition.evaluate(result, false, nil)
condition.evaluate(result, false, false, nil)
}
b.ReportAllocs()
}
@@ -62,7 +62,7 @@ func BenchmarkCondition_evaluateWithBodyStringLenFailure(b *testing.B) {
condition := Condition("len([BODY].name) == 8")
for n := 0; n < b.N; n++ {
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
condition.evaluate(result, false, nil)
condition.evaluate(result, false, false, nil)
}
b.ReportAllocs()
}
@@ -71,7 +71,7 @@ func BenchmarkCondition_evaluateWithStatus(b *testing.B) {
condition := Condition("[STATUS] == 200")
for n := 0; n < b.N; n++ {
result := &Result{HTTPStatus: 200}
condition.evaluate(result, false, nil)
condition.evaluate(result, false, false, nil)
}
b.ReportAllocs()
}
@@ -80,7 +80,7 @@ func BenchmarkCondition_evaluateWithStatusFailure(b *testing.B) {
condition := Condition("[STATUS] == 200")
for n := 0; n < b.N; n++ {
result := &Result{HTTPStatus: 400}
condition.evaluate(result, false, nil)
condition.evaluate(result, false, false, nil)
}
b.ReportAllocs()
}
+16 -7
View File
@@ -62,6 +62,7 @@ func TestCondition_evaluate(t *testing.T) {
Condition Condition
Result *Result
DontResolveFailedConditions bool
ResolveSuccessfulConditions bool
ExpectedSuccess bool
ExpectedOutput string
}{
@@ -184,6 +185,14 @@ func TestCondition_evaluate(t *testing.T) {
ExpectedSuccess: true,
ExpectedOutput: "[BODY] == test",
},
{
Name: "body-resolved-on-success",
Condition: Condition("[BODY].status == UP"),
Result: &Result{Body: []byte("{\"status\":\"UP\"}")},
ResolveSuccessfulConditions: true,
ExpectedSuccess: true,
ExpectedOutput: "[BODY].status (UP) == UP",
},
{
Name: "body-numerical-equal",
Condition: Condition("[BODY] == 123"),
@@ -476,7 +485,7 @@ func TestCondition_evaluate(t *testing.T) {
Condition: Condition("[CERTIFICATE_EXPIRATION] > " + strconv.FormatInt((time.Hour*24*28).Milliseconds(), 10)),
Result: &Result{CertificateExpiration: time.Hour * 24 * 14},
ExpectedSuccess: false,
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (1209600000) > 2419200000",
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (336h) > 2419200000",
},
{
Name: "certificate-expiration-greater-than-duration",
@@ -490,7 +499,7 @@ func TestCondition_evaluate(t *testing.T) {
Condition: Condition("[CERTIFICATE_EXPIRATION] > 48h"),
Result: &Result{CertificateExpiration: 24 * time.Hour},
ExpectedSuccess: false,
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (86400000) > 48h (172800000)",
ExpectedOutput: "[CERTIFICATE_EXPIRATION] (24h) > 48h",
},
{
Name: "no-placeholders",
@@ -757,7 +766,7 @@ func TestCondition_evaluate(t *testing.T) {
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
scenario.Condition.evaluate(scenario.Result, scenario.DontResolveFailedConditions, nil)
scenario.Condition.evaluate(scenario.Result, scenario.DontResolveFailedConditions, scenario.ResolveSuccessfulConditions, nil)
if scenario.Result.ConditionResults[0].Success != scenario.ExpectedSuccess {
t.Errorf("Condition '%s' should have been success=%v", scenario.Condition, scenario.ExpectedSuccess)
}
@@ -771,7 +780,7 @@ func TestCondition_evaluate(t *testing.T) {
func TestCondition_evaluateWithInvalidOperator(t *testing.T) {
condition := Condition("[STATUS] ? 201")
result := &Result{HTTPStatus: 201}
condition.evaluate(result, false, nil)
condition.evaluate(result, false, false, nil)
if result.Success {
t.Error("condition was invalid, result should've been a failure")
}
@@ -791,7 +800,7 @@ func TestConditionEvaluateWithInvalidContextPlaceholder(t *testing.T) {
"max_response_time": 5000,
})
// Simulate suite endpoint evaluation with context
success := condition.evaluate(result, false, ctx) // false = don't skip resolution (default)
success := condition.evaluate(result, false, false, ctx) // false = don't skip resolution (default)
if success {
t.Error("Condition should have failed because [CONTEXT].expected_statusz doesn't exist")
}
@@ -814,7 +823,7 @@ func TestConditionEvaluateWithValidContextPlaceholder(t *testing.T) {
"expected_status": 200,
})
// Simulate suite endpoint evaluation with context
success := condition.evaluate(result, false, ctx)
success := condition.evaluate(result, false, false, ctx)
if !success {
t.Error("Condition should have succeeded")
}
@@ -839,7 +848,7 @@ func TestConditionEvaluateWithMixedValidAndInvalidContext(t *testing.T) {
"valid_key": 5000,
})
// Simulate suite endpoint evaluation with context
success := condition.evaluate(result, false, ctx)
success := condition.evaluate(result, false, false, ctx)
if success {
t.Error("Condition should have failed because [CONTEXT].invalid_key doesn't exist")
}
+55 -14
View File
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"io"
"maps"
"math/rand"
"net"
"net/http"
@@ -50,6 +51,7 @@ const (
TypeSTARTTLS Type = "STARTTLS"
TypeTLS Type = "TLS"
TypeHTTP Type = "HTTP"
TypeGRPC Type = "GRPC"
TypeWS Type = "WEBSOCKET"
TypeSSH Type = "SSH"
TypeUNKNOWN Type = "UNKNOWN"
@@ -177,6 +179,8 @@ func (e *Endpoint) Type() Type {
return TypeTLS
case strings.HasPrefix(e.URL, "http://") || strings.HasPrefix(e.URL, "https://"):
return TypeHTTP
case strings.HasPrefix(e.URL, "grpc://") || strings.HasPrefix(e.URL, "grpcs://"):
return TypeGRPC
case strings.HasPrefix(e.URL, "ws://") || strings.HasPrefix(e.URL, "wss://"):
return TypeWS
case strings.HasPrefix(e.URL, "ssh://"):
@@ -218,12 +222,12 @@ func (e *Endpoint) ValidateAndSetDefaults() error {
e.Headers = make(map[string]string)
}
// Automatically add user agent header if there isn't one specified in the endpoint configuration
if _, userAgentHeaderExists := e.Headers[UserAgentHeader]; !userAgentHeaderExists {
if !hasHeader(e.Headers, UserAgentHeader) {
e.Headers[UserAgentHeader] = GatusUserAgent
}
// Automatically add "Content-Type: application/json" header if there's no Content-Type set
// and endpoint.GraphQL is set to true
if _, contentTypeHeaderExists := e.Headers[ContentTypeHeader]; !contentTypeHeaderExists && e.GraphQL {
if !hasHeader(e.Headers, ContentTypeHeader) && e.GraphQL {
e.Headers[ContentTypeHeader] = "application/json"
}
if len(e.Conditions) == 0 {
@@ -329,7 +333,7 @@ func (e *Endpoint) EvaluateHealthWithContext(context *gontext.Gontext) *Result {
}
// Evaluate the conditions
for _, condition := range processedEndpoint.Conditions {
success := condition.evaluate(result, processedEndpoint.UIConfig.DontResolveFailedConditions, context)
success := condition.evaluate(result, processedEndpoint.UIConfig.DontResolveFailedConditions, processedEndpoint.UIConfig.ResolveSuccessfulConditions, context)
if !success {
result.Success = false
}
@@ -353,6 +357,9 @@ func (e *Endpoint) EvaluateHealthWithContext(context *gontext.Gontext) *Result {
}
result.port = ""
}
if processedEndpoint.UIConfig.HideErrors {
result.Errors = nil
}
if processedEndpoint.UIConfig.HideConditions {
result.ConditionResults = nil
}
@@ -483,12 +490,10 @@ func (e *Endpoint) call(result *Result) {
} else if endpointType == TypeWS {
wsHeaders := map[string]string{}
if e.Headers != nil {
for k, v := range e.Headers {
wsHeaders[k] = v
}
maps.Copy(wsHeaders, e.Headers)
}
if _, exists := wsHeaders["User-Agent"]; !exists {
wsHeaders["User-Agent"] = GatusUserAgent
if !hasHeader(wsHeaders, UserAgentHeader) {
wsHeaders[UserAgentHeader] = GatusUserAgent
}
result.Connected, result.Body, err = client.QueryWebSocket(e.URL, e.getParsedBody(), wsHeaders, e.ClientConfig)
if err != nil {
@@ -497,8 +502,8 @@ func (e *Endpoint) call(result *Result) {
}
result.Duration = time.Since(startTime)
} else if endpointType == TypeSSH {
// If there's no username/password specified, attempt to validate just the SSH banner
if len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0 {
// If there's no username, password or private key specified, attempt to validate just the SSH banner
if e.SSHConfig == nil || (len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0 && len(e.SSHConfig.PrivateKey) == 0) {
result.Connected, result.HTTPStatus, err = client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
if err != nil {
result.AddError(err.Error())
@@ -509,17 +514,35 @@ func (e *Endpoint) call(result *Result) {
return
}
var cli *ssh.Client
result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.ClientConfig)
result.Connected, cli, err = client.CanCreateSSHConnection(strings.TrimPrefix(e.URL, "ssh://"), e.SSHConfig.Username, e.SSHConfig.Password, e.SSHConfig.PrivateKey, e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
}
result.Success, result.HTTPStatus, err = client.ExecuteSSHCommand(cli, e.getParsedBody(), e.ClientConfig)
var output []byte
result.Success, result.HTTPStatus, output, err = client.ExecuteSSHCommand(cli, e.getParsedBody(), e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
}
// Only store the output in result.Body if there's a condition that uses the BodyPlaceholder
if e.needsToReadBody() {
result.Body = output
}
result.Duration = time.Since(startTime)
} else if endpointType == TypeGRPC {
useTLS := strings.HasPrefix(e.URL, "grpcs://")
address := strings.TrimPrefix(strings.TrimPrefix(e.URL, "grpcs://"), "grpc://")
connected, status, err, duration := client.PerformGRPCHealthCheck(address, useTLS, e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
}
result.Connected = connected
result.Duration = duration
if e.needsToReadBody() {
result.Body = []byte(fmt.Sprintf("{\"status\":\"%s\"}", status))
}
} else {
response, err = client.GetHTTPClient(e.ClientConfig).Do(request)
result.Duration = time.Since(startTime)
@@ -558,20 +581,28 @@ func (e *Endpoint) buildHTTPRequest() *http.Request {
request, _ := http.NewRequest(e.Method, e.URL, bodyBuffer)
for k, v := range e.Headers {
request.Header.Set(k, v)
if k == HostHeader {
if strings.EqualFold(k, HostHeader) {
request.Host = v
}
}
return request
}
// needsToReadBody checks if there's any condition that requires the response Body to be read
// needsToReadBody checks if there's any condition or store mapping that requires the response Body to be read
func (e *Endpoint) needsToReadBody() bool {
for _, condition := range e.Conditions {
if condition.hasBodyPlaceholder() {
return true
}
}
// Check store values for body placeholders
if e.Store != nil {
for _, value := range e.Store {
if strings.Contains(value, BodyPlaceholder) {
return true
}
}
}
return false
}
@@ -594,3 +625,13 @@ func (e *Endpoint) needsToRetrieveIP() bool {
}
return false
}
// hasHeader checks if a header exists in the map using a case-insensitive lookup
func hasHeader(headers map[string]string, name string) bool {
for k := range headers {
if strings.EqualFold(k, name) {
return true
}
}
return false
}
+341 -8
View File
@@ -21,6 +21,30 @@ import (
"github.com/TwiN/gatus/v5/test"
)
func TestHasHeader(t *testing.T) {
scenarios := []struct {
name string
headers map[string]string
lookup string
expected bool
}{
{name: "exact-match", headers: map[string]string{"User-Agent": "test"}, lookup: "User-Agent", expected: true},
{name: "lowercase-lookup", headers: map[string]string{"User-Agent": "test"}, lookup: "user-agent", expected: true},
{name: "uppercase-lookup", headers: map[string]string{"user-agent": "test"}, lookup: "USER-AGENT", expected: true},
{name: "mixed-case", headers: map[string]string{"UsEr-AgEnT": "test"}, lookup: "uSeR-aGeNt", expected: true},
{name: "not-found", headers: map[string]string{"Content-Type": "test"}, lookup: "User-Agent", expected: false},
{name: "empty-headers", headers: map[string]string{}, lookup: "User-Agent", expected: false},
{name: "nil-headers", headers: nil, lookup: "User-Agent", expected: false},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
if result := hasHeader(scenario.headers, scenario.lookup); result != scenario.expected {
t.Errorf("expected %v, got %v", scenario.expected, result)
}
})
}
}
func TestEndpoint(t *testing.T) {
defer client.InjectHTTPClient(nil)
scenarios := []struct {
@@ -268,6 +292,31 @@ func TestEndpoint(t *testing.T) {
}
}
func TestEndpoint_ResolveSuccessfulConditions(t *testing.T) {
defer client.InjectHTTPClient(nil)
endpoint := Endpoint{
Name: "test-endpoint",
URL: "https://example.com/health",
Conditions: []Condition{"[BODY].status == UP"},
UIConfig: &ui.Config{ResolveSuccessfulConditions: true},
}
mockResponse := test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{"status":"UP"}`))}
})
client.InjectHTTPClient(&http.Client{Transport: mockResponse})
if err := endpoint.ValidateAndSetDefaults(); err != nil {
t.Fatalf("ValidateAndSetDefaults failed: %v", err)
}
result := endpoint.EvaluateHealth()
if len(result.ConditionResults) != 1 {
t.Fatalf("expected 1 condition result, got %d", len(result.ConditionResults))
}
expectedCondition := "[BODY].status (UP) == UP"
if result.ConditionResults[0].Condition != expectedCondition {
t.Errorf("expected condition to be '%s', got '%s'", expectedCondition, result.ConditionResults[0].Condition)
}
}
func TestEndpoint_IsEnabled(t *testing.T) {
if !(&Endpoint{Enabled: nil}).IsEnabled() {
t.Error("endpoint.IsEnabled() should've returned true, because Enabled was set to nil")
@@ -511,26 +560,40 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
name string
username string
password string
privateKey string
expectedErr error
}{
{
name: "fail when has no user",
name: "fail when has no user but has password",
username: "",
password: "password",
expectedErr: ssh.ErrEndpointWithoutSSHUsername,
},
{
name: "fail when has no password",
username: "username",
password: "",
expectedErr: ssh.ErrEndpointWithoutSSHPassword,
name: "fail when has no user but has private key",
username: "",
privateKey: "-----BEGIN",
expectedErr: ssh.ErrEndpointWithoutSSHUsername,
},
{
name: "success when all fields are set",
name: "fail when has no password or private key",
username: "username",
password: "",
privateKey: "",
expectedErr: ssh.ErrEndpointWithoutSSHAuth,
},
{
name: "success when username and password are set",
username: "username",
password: "password",
expectedErr: nil,
},
{
name: "success when username and private key are set",
username: "username",
privateKey: "-----BEGIN",
expectedErr: nil,
},
}
for _, scenario := range scenarios {
@@ -539,8 +602,9 @@ func TestEndpoint_ValidateAndSetDefaultsWithSSH(t *testing.T) {
Name: "ssh-test",
URL: "https://example.com",
SSHConfig: &ssh.Config{
Username: scenario.username,
Password: scenario.password,
Username: scenario.username,
Password: scenario.password,
PrivateKey: scenario.privateKey,
},
Conditions: []Condition{Condition("[STATUS] == 0")},
}
@@ -682,6 +746,76 @@ func TestEndpoint_buildHTTPRequestWithHostHeader(t *testing.T) {
}
}
func TestEndpoint_buildHTTPRequestWithLowercaseUserAgent(t *testing.T) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Conditions: []Condition{condition},
Headers: map[string]string{
"user-agent": "CustomAgent/1.0",
},
}
err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
if _, exists := endpoint.Headers[UserAgentHeader]; exists {
t.Error("User-Agent header should not have been added since user-agent was already specified")
}
request := endpoint.buildHTTPRequest()
if userAgent := request.Header.Get("User-Agent"); userAgent != "CustomAgent/1.0" {
t.Errorf("request.Header.Get(User-Agent) should've been CustomAgent/1.0, but was %s", userAgent)
}
}
func TestEndpoint_buildHTTPRequestWithLowercaseContentType(t *testing.T) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{
Name: "website-graphql",
URL: "https://twin.sh/graphql",
Method: "POST",
Conditions: []Condition{condition},
GraphQL: true,
Headers: map[string]string{
"content-type": "application/graphql",
},
Body: `{ users { id } }`,
}
err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
if _, exists := endpoint.Headers[ContentTypeHeader]; exists {
t.Error("Content-Type header should not have been added since content-type was already specified")
}
request := endpoint.buildHTTPRequest()
if contentType := request.Header.Get("Content-Type"); contentType != "application/graphql" {
t.Errorf("request.Header.Get(Content-Type) should've been application/graphql, but was %s", contentType)
}
}
func TestEndpoint_buildHTTPRequestWithLowercaseHostHeader(t *testing.T) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{
Name: "website-health",
URL: "https://twin.sh/health",
Method: "POST",
Conditions: []Condition{condition},
Headers: map[string]string{
"host": "example.com",
},
}
err := endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatal("did not expect an error, got", err)
}
request := endpoint.buildHTTPRequest()
if request.Host != "example.com" {
t.Error("request.Host should've been example.com, but was", request.Host)
}
}
func TestEndpoint_buildHTTPRequestWithGraphQLEnabled(t *testing.T) {
condition := Condition("[STATUS] == 200")
endpoint := Endpoint{
@@ -914,6 +1048,40 @@ func TestEndpoint_needsToReadBody(t *testing.T) {
if !(&Endpoint{Conditions: []Condition{bodyConditionWithLength, statusCondition}}).needsToReadBody() {
t.Error("expected true, got false")
}
// Test store configuration with body placeholder
storeWithBodyPlaceholder := map[string]string{
"token": "[BODY].accessToken",
}
if !(&Endpoint{
Conditions: []Condition{statusCondition},
Store: storeWithBodyPlaceholder,
}).needsToReadBody() {
t.Error("expected true when store has body placeholder, got false")
}
// Test store configuration without body placeholder
storeWithoutBodyPlaceholder := map[string]string{
"status": "[STATUS]",
}
if (&Endpoint{
Conditions: []Condition{statusCondition},
Store: storeWithoutBodyPlaceholder,
}).needsToReadBody() {
t.Error("expected false when store has no body placeholder, got true")
}
// Test empty store
if (&Endpoint{
Conditions: []Condition{statusCondition},
Store: map[string]string{},
}).needsToReadBody() {
t.Error("expected false when store is empty, got true")
}
// Test nil store
if (&Endpoint{
Conditions: []Condition{statusCondition},
Store: nil,
}).needsToReadBody() {
t.Error("expected false when store is nil, got true")
}
}
func TestEndpoint_needsToRetrieveDomainExpiration(t *testing.T) {
@@ -1414,3 +1582,168 @@ func TestEndpoint_preprocessWithContext(t *testing.T) {
})
}
}
func TestEndpoint_HideUIFeatures(t *testing.T) {
defer client.InjectHTTPClient(nil)
tests := []struct {
name string
endpoint Endpoint
mockResponse test.MockRoundTripper
checkHostname bool
expectHostname string
checkErrors bool
expectErrors bool
checkConditions bool
expectConditions bool
checkErrorContent string
}{
{
name: "hide-conditions",
endpoint: Endpoint{
Name: "test-endpoint",
URL: "https://example.com/health",
Conditions: []Condition{"[STATUS] == 200", "[BODY].status == UP"},
UIConfig: &ui.Config{HideConditions: true},
},
mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{"status": "UP"}`))}
}),
checkConditions: true,
expectConditions: false,
},
{
name: "hide-hostname",
endpoint: Endpoint{
Name: "test-endpoint",
URL: "https://example.com/health",
Conditions: []Condition{"[STATUS] == 200"},
UIConfig: &ui.Config{HideHostname: true},
},
mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
checkHostname: true,
expectHostname: "",
},
{
name: "hide-url-in-errors",
endpoint: Endpoint{
Name: "test-endpoint",
URL: "https://example.com/health",
Conditions: []Condition{"[CONNECTED] == true"},
UIConfig: &ui.Config{HideURL: true},
ClientConfig: &client.Config{Timeout: time.Millisecond},
},
mockResponse: nil,
checkErrors: true,
expectErrors: true,
checkErrorContent: "<redacted>",
},
{
name: "hide-port-in-errors",
endpoint: Endpoint{
Name: "test-endpoint",
URL: "https://example.com:9999/health",
Conditions: []Condition{"[CONNECTED] == true"},
UIConfig: &ui.Config{HidePort: true},
ClientConfig: &client.Config{Timeout: time.Millisecond},
},
mockResponse: nil,
checkErrors: true,
expectErrors: true,
checkErrorContent: "<redacted>",
},
{
name: "hide-errors",
endpoint: Endpoint{
Name: "test-endpoint",
URL: "https://example.com/health",
Conditions: []Condition{"[CONNECTED] == true"},
UIConfig: &ui.Config{HideErrors: true},
ClientConfig: &client.Config{Timeout: time.Millisecond},
},
mockResponse: nil,
checkErrors: true,
expectErrors: false,
},
{
name: "dont-resolve-failed-conditions",
endpoint: Endpoint{
Name: "test-endpoint",
URL: "https://example.com/health",
Conditions: []Condition{"[STATUS] == 200"},
UIConfig: &ui.Config{DontResolveFailedConditions: true},
},
mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusBadGateway, Body: http.NoBody}
}),
checkConditions: true,
expectConditions: true,
},
{
name: "multiple-hide-features",
endpoint: Endpoint{
Name: "test-endpoint",
URL: "https://example.com/health",
Conditions: []Condition{"[STATUS] == 200"},
UIConfig: &ui.Config{HideConditions: true, HideHostname: true, HideErrors: true},
},
mockResponse: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
checkConditions: true,
expectConditions: false,
checkHostname: true,
expectHostname: "",
checkErrors: true,
expectErrors: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.mockResponse != nil {
mockClient := &http.Client{Transport: tt.mockResponse}
if tt.endpoint.ClientConfig != nil && tt.endpoint.ClientConfig.Timeout > 0 {
mockClient.Timeout = tt.endpoint.ClientConfig.Timeout
}
client.InjectHTTPClient(mockClient)
} else {
client.InjectHTTPClient(nil)
}
err := tt.endpoint.ValidateAndSetDefaults()
if err != nil {
t.Fatalf("ValidateAndSetDefaults failed: %v", err)
}
result := tt.endpoint.EvaluateHealth()
if tt.checkHostname {
if result.Hostname != tt.expectHostname {
t.Errorf("Expected hostname '%s', got '%s'", tt.expectHostname, result.Hostname)
}
}
if tt.checkErrors {
hasErrors := len(result.Errors) > 0
if hasErrors != tt.expectErrors {
t.Errorf("Expected errors=%v, got errors=%v (actual errors: %v)", tt.expectErrors, hasErrors, result.Errors)
}
if tt.checkErrorContent != "" && len(result.Errors) > 0 {
found := false
for _, err := range result.Errors {
if strings.Contains(err, tt.checkErrorContent) {
found = true
break
}
}
if !found {
t.Errorf("Expected error to contain '%s', but got: %v", tt.checkErrorContent, result.Errors)
}
}
}
if tt.checkConditions {
hasConditions := len(result.ConditionResults) > 0
if hasConditions != tt.expectConditions {
t.Errorf("Expected conditions=%v, got conditions=%v (actual: %v)", tt.expectConditions, hasConditions, result.ConditionResults)
}
}
})
}
}
+3 -6
View File
@@ -1,6 +1,7 @@
package endpoint
import (
"slices"
"time"
)
@@ -66,11 +67,7 @@ type Result struct {
// AddError adds an error to the result's list of errors.
// It also ensures that there are no duplicates.
func (r *Result) AddError(error string) {
for _, resultError := range r.Errors {
if resultError == error {
// If the error already exists, don't add it
return
}
if !slices.Contains(r.Errors, error) {
r.Errors = append(r.Errors, error+"")
}
r.Errors = append(r.Errors, error+"")
}
+11 -8
View File
@@ -8,26 +8,29 @@ var (
// ErrEndpointWithoutSSHUsername is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a user.
ErrEndpointWithoutSSHUsername = errors.New("you must specify a username for each SSH endpoint")
// ErrEndpointWithoutSSHPassword is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password.
ErrEndpointWithoutSSHPassword = errors.New("you must specify a password for each SSH endpoint")
// ErrEndpointWithoutSSHAuth is the error with which Gatus will panic if an endpoint with SSH monitoring is configured without a password or private key.
ErrEndpointWithoutSSHAuth = errors.New("you must specify a password or private-key for each SSH endpoint")
)
type Config struct {
Username string `yaml:"username,omitempty"`
Password string `yaml:"password,omitempty"`
Username string `yaml:"username,omitempty"`
Password string `yaml:"password,omitempty"`
PrivateKey string `yaml:"private-key,omitempty"`
}
// Validate the SSH configuration
func (cfg *Config) Validate() error {
// If there's no username and password, this endpoint can still check the SSH banner, so the endpoint is still valid
if len(cfg.Username) == 0 && len(cfg.Password) == 0 {
// If there's no username, password, or private key, this endpoint can still check the SSH banner, so the endpoint is still valid
if len(cfg.Username) == 0 && len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {
return nil
}
// If any authentication method is provided (password or private key), a username is required
if len(cfg.Username) == 0 {
return ErrEndpointWithoutSSHUsername
}
if len(cfg.Password) == 0 {
return ErrEndpointWithoutSSHPassword
// If a username is provided, require at least a password or a private key
if len(cfg.Password) == 0 && len(cfg.PrivateKey) == 0 {
return ErrEndpointWithoutSSHAuth
}
return nil
}
+18 -3
View File
@@ -5,7 +5,7 @@ import (
"testing"
)
func TestSSH_validate(t *testing.T) {
func TestSSH_validatePasswordCfg(t *testing.T) {
cfg := &Config{}
if err := cfg.Validate(); err != nil {
t.Error("didn't expect an error")
@@ -13,11 +13,26 @@ func TestSSH_validate(t *testing.T) {
cfg.Username = "username"
if err := cfg.Validate(); err == nil {
t.Error("expected an error")
} else if !errors.Is(err, ErrEndpointWithoutSSHPassword) {
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHPassword, err)
} else if !errors.Is(err, ErrEndpointWithoutSSHAuth) {
t.Errorf("expected error to be '%v', got '%v'", ErrEndpointWithoutSSHAuth, err)
}
cfg.Password = "password"
if err := cfg.Validate(); err != nil {
t.Errorf("expected no error, got '%v'", err)
}
}
func TestSSH_validatePrivateKeyCfg(t *testing.T) {
t.Run("fail when username missing but private key provided", func(t *testing.T) {
cfg := &Config{PrivateKey: "-----BEGIN"}
if err := cfg.Validate(); !errors.Is(err, ErrEndpointWithoutSSHUsername) {
t.Fatalf("expected ErrEndpointWithoutSSHUsername, got %v", err)
}
})
t.Run("success when username with private key", func(t *testing.T) {
cfg := &Config{Username: "user", PrivateKey: "-----BEGIN"}
if err := cfg.Validate(); err != nil {
t.Fatalf("expected no error, got %v", err)
}
})
}
+8
View File
@@ -16,9 +16,15 @@ type Config struct {
// HidePort whether to hide the port in the Result
HidePort bool `yaml:"hide-port"`
// HideErrors whether to hide the errors in the Result
HideErrors bool `yaml:"hide-errors"`
// DontResolveFailedConditions whether to resolve failed conditions in the Result for display in the UI
DontResolveFailedConditions bool `yaml:"dont-resolve-failed-conditions"`
// ResolveSuccessfulConditions whether to resolve successful conditions in the Result for display in the UI
ResolveSuccessfulConditions bool `yaml:"resolve-successful-conditions"`
// Badge is the configuration for the badges generated
Badge *Badge `yaml:"badge"`
}
@@ -58,7 +64,9 @@ func GetDefaultConfig() *Config {
HideHostname: false,
HideURL: false,
HidePort: false,
HideErrors: false,
DontResolveFailedConditions: false,
ResolveSuccessfulConditions: false,
HideConditions: false,
Badge: &Badge{
ResponseTime: &ResponseTime{
+5 -5
View File
@@ -340,9 +340,9 @@ func TestGontext_ConcurrentAccess(t *testing.T) {
done := make(chan bool, 10)
// Start 5 goroutines that read values
for i := 0; i < 5; i++ {
for i := range 5 {
go func(id int) {
for j := 0; j < 100; j++ {
for range 100 {
_, err := ctx.Get("counter")
if err != nil {
t.Errorf("Reader %d error: %v", id, err)
@@ -353,9 +353,9 @@ func TestGontext_ConcurrentAccess(t *testing.T) {
}
// Start 5 goroutines that write values
for i := 0; i < 5; i++ {
for i := range 5 {
go func(id int) {
for j := 0; j < 100; j++ {
for j := range 100 {
err := ctx.Set("counter", id*1000+j)
if err != nil {
t.Errorf("Writer %d error: %v", id, err)
@@ -366,7 +366,7 @@ func TestGontext_ConcurrentAccess(t *testing.T) {
}
// Wait for all goroutines to complete
for i := 0; i < 10; i++ {
for range 10 {
<-done
}
}
+3 -1
View File
@@ -15,5 +15,7 @@ func sanitize(s string) string {
s = strings.ReplaceAll(s, ",", "-")
s = strings.ReplaceAll(s, " ", "-")
s = strings.ReplaceAll(s, "#", "-")
s = strings.ReplaceAll(s, "+", "-")
s = strings.ReplaceAll(s, "&", "-")
return s
}
}
+15
View File
@@ -29,6 +29,21 @@ func TestConvertGroupAndNameToKey(t *testing.T) {
Name: "name",
ExpectedOutput: "_name",
},
{
GroupName: "API (v1)",
Name: "endpoint",
ExpectedOutput: "api-(v1)_endpoint",
},
{
GroupName: "website (admin)",
Name: "test",
ExpectedOutput: "website-(admin)_test",
},
{
GroupName: "search",
Name: "query&filter",
ExpectedOutput: "search_query-filter",
},
}
for _, scenario := range scenarios {
t.Run(scenario.ExpectedOutput, func(t *testing.T) {
+13 -27
View File
@@ -3,6 +3,7 @@ package maintenance
import (
"errors"
"fmt"
"slices"
"strconv"
"strings"
"time"
@@ -31,17 +32,17 @@ var (
//
// Uses UTC by default.
type Config struct {
Enabled *bool `yaml:"enabled"` // Whether the maintenance period is enabled. Enabled by default if nil.
Start string `yaml:"start"` // Time at which the maintenance period starts (e.g. 23:00)
Duration time.Duration `yaml:"duration"` // Duration of the maintenance period (e.g. 4h)
Timezone string `yaml:"timezone"` // Timezone in string format which the maintenance period is configured (e.g. America/Sao_Paulo)
Enabled *bool `yaml:"enabled"` // Whether the maintenance period is enabled. Enabled by default if nil.
Start string `yaml:"start,omitempty"` // Time at which the maintenance period starts (e.g. 23:00)
Duration time.Duration `yaml:"duration,omitempty"` // Duration of the maintenance period (e.g. 4h)
Timezone string `yaml:"timezone,omitempty"` // Timezone in string format which the maintenance period is configured (e.g. America/Sao_Paulo)
// Every is a list of days of the week during which maintenance period applies.
// See longDayNames for list of valid values.
// Every day if empty.
Every []string `yaml:"every"`
Every []string `yaml:"every,omitempty"`
TimezoneLocation *time.Location // Timezone in location format which the maintenance period is configured
timezoneLocation *time.Location
durationToStartFromMidnight time.Duration
}
@@ -70,13 +71,7 @@ func (c *Config) ValidateAndSetDefaults() error {
return nil
}
for _, day := range c.Every {
isDayValid := false
for _, longDayName := range longDayNames {
if day == longDayName {
isDayValid = true
break
}
}
isDayValid := slices.Contains(longDayNames, day)
if !isDayValid {
return errInvalidDayName
}
@@ -90,13 +85,13 @@ func (c *Config) ValidateAndSetDefaults() error {
return errInvalidMaintenanceDuration
}
if c.Timezone != "" {
c.TimezoneLocation, err = time.LoadLocation(c.Timezone)
c.timezoneLocation, err = time.LoadLocation(c.Timezone)
if err != nil {
return fmt.Errorf("%w: %w", errInvalidTimezone, err)
}
} else {
c.Timezone = "UTC"
c.TimezoneLocation = time.UTC
c.timezoneLocation = time.UTC
}
return nil
}
@@ -107,8 +102,8 @@ func (c *Config) IsUnderMaintenance() bool {
return false
}
now := time.Now()
if c.TimezoneLocation != nil {
now = now.In(c.TimezoneLocation)
if c.timezoneLocation != nil {
now = now.In(c.timezoneLocation)
}
adjustedDate := now.Day()
if now.Hour() < int(c.durationToStartFromMidnight.Hours()) {
@@ -118,7 +113,7 @@ func (c *Config) IsUnderMaintenance() bool {
// Set to midnight prior to adding duration
dayWhereMaintenancePeriodWouldStart := time.Date(now.Year(), now.Month(), adjustedDate, 0, 0, 0, 0, now.Location())
hasMaintenanceEveryDay := len(c.Every) == 0
hasMaintenancePeriodScheduledToStartOnThatWeekday := c.hasDay(dayWhereMaintenancePeriodWouldStart.Weekday().String())
hasMaintenancePeriodScheduledToStartOnThatWeekday := slices.Contains(c.Every, dayWhereMaintenancePeriodWouldStart.Weekday().String())
if !hasMaintenanceEveryDay && !hasMaintenancePeriodScheduledToStartOnThatWeekday {
// The day when the maintenance period would start is not scheduled
// to have any maintenance, so we can just return false.
@@ -129,15 +124,6 @@ func (c *Config) IsUnderMaintenance() bool {
return now.After(startOfMaintenancePeriod) && now.Before(endOfMaintenancePeriod)
}
func (c *Config) hasDay(day string) bool {
for _, d := range c.Every {
if d == day {
return true
}
}
return false
}
func hhmmToDuration(s string) (time.Duration, error) {
if len(s) != 5 {
return 0, errInvalidMaintenanceStartFormat
+12
View File
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/TwiN/gatus/v5/config/endpoint"
@@ -175,10 +176,12 @@ func StoreResultValues(ctx *gontext.Gontext, mappings map[string]string, result
return nil, nil
}
storedValues := make(map[string]interface{})
var extractionErrors []string
for contextKey, placeholder := range mappings {
value, err := extractValueForStorage(placeholder, result)
if err != nil {
// Continue storing other values even if one fails
extractionErrors = append(extractionErrors, fmt.Sprintf("%s: %v", contextKey, err))
storedValues[contextKey] = fmt.Sprintf("ERROR: %v", err)
continue
}
@@ -187,6 +190,10 @@ func StoreResultValues(ctx *gontext.Gontext, mappings map[string]string, result
}
storedValues[contextKey] = value
}
// Return an error if any values failed to extract
if len(extractionErrors) > 0 {
return storedValues, fmt.Errorf("failed to extract values: %s", strings.Join(extractionErrors, "; "))
}
return storedValues, nil
}
@@ -197,6 +204,11 @@ func extractValueForStorage(placeholder string, result *endpoint.Result) (interf
if err != nil {
return nil, err
}
// Check if the resolution resulted in an INVALID placeholder
// This happens when a path doesn't exist (e.g., [BODY].nonexistent)
if strings.HasSuffix(resolved, " "+endpoint.InvalidConditionElementSuffix) {
return nil, fmt.Errorf("invalid path: %s", strings.TrimSuffix(resolved, " "+endpoint.InvalidConditionElementSuffix))
}
// Try to parse as number or boolean to store as proper types
// Try int first for whole numbers
if num, err := strconv.ParseInt(resolved, 10, 64); err == nil {
+45
View File
@@ -1,6 +1,7 @@
package suite
import (
"strings"
"testing"
"time"
@@ -240,6 +241,50 @@ func TestStoreResultValues(t *testing.T) {
}
}
func TestStoreResultValuesWithInvalidPath(t *testing.T) {
ctx := gontext.New(map[string]interface{}{})
result := &endpoint.Result{
HTTPStatus: 200,
Body: []byte(`{"data": {"name": "john"}}`),
}
// Define store mappings with invalid paths
mappings := map[string]string{
"valid_status": "[STATUS]",
"invalid_token": "[BODY].accessToken", // This path doesn't exist
"invalid_nested": "[BODY].user.id.invalid", // This nested path doesn't exist
}
// Store values - should return error for invalid paths
stored, err := StoreResultValues(ctx, mappings, result)
if err == nil {
t.Fatal("Expected error when storing invalid paths, got nil")
}
// Check that the error message contains information about the invalid paths
if !strings.Contains(err.Error(), "invalid_token") {
t.Errorf("Error should mention invalid_token, got: %v", err)
}
if !strings.Contains(err.Error(), "invalid path") {
t.Errorf("Error should mention 'invalid path', got: %v", err)
}
// Verify that valid values were still stored
if stored["valid_status"] != int64(200) {
t.Errorf("Expected valid_status=200, got %v", stored["valid_status"])
}
// Verify that invalid values show error messages in stored map
if !strings.Contains(stored["invalid_token"].(string), "ERROR") {
t.Errorf("Expected invalid_token to contain ERROR, got %v", stored["invalid_token"])
}
// Verify that invalid values are NOT in context
_, err = ctx.Get("invalid_token")
if err == nil {
t.Error("Invalid token should not be stored in context")
}
// Verify that valid value IS in context
val, err := ctx.Get("valid_status")
if err != nil || val != int64(200) {
t.Errorf("Expected valid_status=200 in context, got %v, err=%v", val, err)
}
}
func TestSuite_ExecuteWithAlwaysRunEndpoints(t *testing.T) {
suite := &Suite{
Name: "test-suite",
+162
View File
@@ -0,0 +1,162 @@
package sshtunnel
import (
"fmt"
"net"
"sync"
"time"
"golang.org/x/crypto/ssh"
)
// Config represents the configuration for an SSH tunnel
type Config struct {
Type string `yaml:"type"`
Host string `yaml:"host"`
Port int `yaml:"port,omitempty"`
Username string `yaml:"username"`
PrivateKey string `yaml:"private-key,omitempty"`
Password string `yaml:"password,omitempty"`
}
// ValidateAndSetDefaults validates the SSH tunnel configuration and sets defaults
func (c *Config) ValidateAndSetDefaults() error {
if c.Type != "SSH" {
return fmt.Errorf("unsupported tunnel type: %s", c.Type)
}
if c.Host == "" {
return fmt.Errorf("host is required")
}
if c.Username == "" {
return fmt.Errorf("username is required")
}
if c.PrivateKey == "" && c.Password == "" {
return fmt.Errorf("either private-key or password is required")
}
if c.Port == 0 {
c.Port = 22
}
return nil
}
// SSHTunnel represents an SSH tunnel connection
type SSHTunnel struct {
config *Config
mu sync.RWMutex
client *ssh.Client
// Cached authentication methods to avoid reparsing private keys
authMethods []ssh.AuthMethod
}
// New creates a new SSH tunnel with the given configuration
func New(config *Config) *SSHTunnel {
tunnel := &SSHTunnel{
config: config,
}
// Parse authentication methods once during initialization to avoid
// expensive cryptographic operations on every connection attempt
if config.PrivateKey != "" {
if signer, err := ssh.ParsePrivateKey([]byte(config.PrivateKey)); err == nil {
tunnel.authMethods = []ssh.AuthMethod{ssh.PublicKeys(signer)}
}
// Note: We don't return error here to maintain backward compatibility.
// Invalid keys will be caught during first connection attempt.
} else if config.Password != "" {
tunnel.authMethods = []ssh.AuthMethod{ssh.Password(config.Password)}
}
return tunnel
}
// Connect establishes the SSH connection
func (t *SSHTunnel) Connect() error {
t.mu.Lock()
defer t.mu.Unlock()
return t.connectUnsafe()
}
// connectUnsafe establishes the SSH connection without acquiring locks
// Must be called with t.mu.Lock() already held
func (t *SSHTunnel) connectUnsafe() error {
// Use cached authentication methods to avoid expensive crypto operations
if len(t.authMethods) == 0 {
return fmt.Errorf("no authentication method available")
}
config := &ssh.ClientConfig{
User: t.config.Username,
Timeout: 30 * time.Second,
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // Skip host key verification
Auth: t.authMethods, // Use pre-parsed authentication
}
// Connect to SSH server
addr := fmt.Sprintf("%s:%d", t.config.Host, t.config.Port)
client, err := ssh.Dial("tcp", addr, config)
if err != nil {
return fmt.Errorf("SSH connection failed: %w", err)
}
t.client = client
return nil
}
// Close closes the SSH connection
func (t *SSHTunnel) Close() error {
t.mu.Lock()
defer t.mu.Unlock()
if t.client != nil {
err := t.client.Close()
t.client = nil
return err
}
return nil
}
// Dial creates a connection through the SSH tunnel
func (t *SSHTunnel) Dial(network, addr string) (net.Conn, error) {
t.mu.RLock()
client := t.client
t.mu.RUnlock()
// Ensure we have an SSH connection
if client == nil {
// Use write lock to prevent race condition during connection
t.mu.Lock()
// Double-check client after acquiring lock
if t.client == nil {
if err := t.connectUnsafe(); err != nil {
t.mu.Unlock()
return nil, err
}
}
client = t.client
t.mu.Unlock()
}
// Attempt dial with exponential backoff retry
const maxRetries = 3
const baseDelay = 500 * time.Millisecond
var lastErr error
for attempt := range maxRetries {
if attempt > 0 {
// Exponential backoff: 500ms, 1s, 2s
delay := baseDelay << (attempt - 1)
time.Sleep(delay)
// Close stale connection and reconnect
t.mu.Lock()
if t.client != nil {
_ = t.client.Close()
t.client = nil
}
if err := t.connectUnsafe(); err != nil {
t.mu.Unlock()
lastErr = fmt.Errorf("reconnect attempt %d failed: %w", attempt, err)
continue
}
client = t.client
t.mu.Unlock()
}
conn, err := client.Dial(network, addr)
if err == nil {
return conn, nil
}
lastErr = err
}
return nil, fmt.Errorf("SSH tunnel dial failed after %d attempts: %w", maxRetries, lastErr)
}
@@ -0,0 +1,158 @@
package sshtunnel
import (
"testing"
)
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
tests := []struct {
name string
config *Config
wantErr bool
errMsg string
}{
{
name: "valid SSH config with private key",
config: &Config{
Type: "SSH",
Host: "example.com",
Username: "test",
PrivateKey: "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----",
},
wantErr: false,
},
{
name: "valid SSH config with password",
config: &Config{
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
},
wantErr: false,
},
{
name: "valid SSH config with custom port",
config: &Config{
Type: "SSH",
Host: "example.com",
Port: 2222,
Username: "test",
Password: "secret",
},
wantErr: false,
},
{
name: "sets default port 22",
config: &Config{
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
},
wantErr: false,
},
{
name: "invalid type",
config: &Config{
Type: "INVALID",
Host: "example.com",
Username: "test",
Password: "secret",
},
wantErr: true,
errMsg: "unsupported tunnel type: INVALID",
},
{
name: "missing host",
config: &Config{
Type: "SSH",
Username: "test",
Password: "secret",
},
wantErr: true,
errMsg: "host is required",
},
{
name: "missing username",
config: &Config{
Type: "SSH",
Host: "example.com",
Password: "secret",
},
wantErr: true,
errMsg: "username is required",
},
{
name: "missing authentication",
config: &Config{
Type: "SSH",
Host: "example.com",
Username: "test",
},
wantErr: true,
errMsg: "either private-key or password is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
originalPort := tt.config.Port
err := tt.config.ValidateAndSetDefaults()
if tt.wantErr {
if err == nil {
t.Errorf("ValidateAndSetDefaults() expected error but got none")
return
}
if err.Error() != tt.errMsg {
t.Errorf("ValidateAndSetDefaults() error = %v, want %v", err.Error(), tt.errMsg)
}
return
}
if err != nil {
t.Errorf("ValidateAndSetDefaults() unexpected error = %v", err)
return
}
// Check that default port is set
if originalPort == 0 && tt.config.Port != 22 {
t.Errorf("ValidateAndSetDefaults() expected default port 22, got %d", tt.config.Port)
}
})
}
}
func TestNew(t *testing.T) {
config := &Config{
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
}
tunnel := New(config)
if tunnel == nil {
t.Error("New() returned nil")
return
}
if tunnel.config != config {
t.Error("New() did not set config correctly")
}
}
func TestSSHTunnel_Close(t *testing.T) {
config := &Config{
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
}
tunnel := New(config)
// Test closing when no client is set
err := tunnel.Close()
if err != nil {
t.Errorf("Close() with no client returned error: %v", err)
}
// Test closing multiple times
err = tunnel.Close()
if err != nil {
t.Errorf("Close() called twice returned error: %v", err)
}
}
+70
View File
@@ -0,0 +1,70 @@
package tunneling
import (
"fmt"
"strings"
"sync"
"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel"
)
// Config represents the tunneling configuration
type Config struct {
// Tunnels is a map of SSH tunnel configurations in which the key is the name of the tunnel
Tunnels map[string]*sshtunnel.Config `yaml:",inline"`
mu sync.RWMutex `yaml:"-"`
connections map[string]*sshtunnel.SSHTunnel `yaml:"-"`
}
// ValidateAndSetDefaults validates the tunneling configuration and sets defaults
func (tc *Config) ValidateAndSetDefaults() error {
if tc.connections == nil {
tc.connections = make(map[string]*sshtunnel.SSHTunnel)
}
for name, config := range tc.Tunnels {
if err := config.ValidateAndSetDefaults(); err != nil {
return fmt.Errorf("tunnel '%s': %w", name, err)
}
}
return nil
}
// GetTunnel returns the SSH tunnel for the given name, creating it if necessary
func (tc *Config) GetTunnel(name string) (*sshtunnel.SSHTunnel, error) {
if name == "" {
return nil, fmt.Errorf("tunnel name cannot be empty")
}
tc.mu.Lock()
defer tc.mu.Unlock()
// Check if tunnel already exists
if tunnel, exists := tc.connections[name]; exists {
return tunnel, nil
}
// Get config for this tunnel
config, exists := tc.Tunnels[name]
if !exists {
return nil, fmt.Errorf("tunnel '%s' not found in configuration", name)
}
// Create and store new tunnel
tunnel := sshtunnel.New(config)
tc.connections[name] = tunnel
return tunnel, nil
}
// Close closes all SSH tunnel connections
func (tc *Config) Close() error {
tc.mu.Lock()
defer tc.mu.Unlock()
var errors []string
for name, tunnel := range tc.connections {
if err := tunnel.Close(); err != nil {
errors = append(errors, fmt.Sprintf("tunnel '%s': %v", name, err))
}
delete(tc.connections, name)
}
if len(errors) > 0 {
return fmt.Errorf("failed to close tunnels: %s", strings.Join(errors, ", "))
}
return nil
}
+191
View File
@@ -0,0 +1,191 @@
package tunneling
import (
"testing"
"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel"
)
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
tests := []struct {
name string
config *Config
wantErr bool
errMsg string
}{
{
name: "valid config with SSH tunnel",
config: &Config{
Tunnels: map[string]*sshtunnel.Config{
"test": {
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
},
},
},
wantErr: false,
},
{
name: "multiple valid tunnels",
config: &Config{
Tunnels: map[string]*sshtunnel.Config{
"tunnel1": {
Type: "SSH",
Host: "host1.com",
Username: "user1",
PrivateKey: "key1",
},
"tunnel2": {
Type: "SSH",
Host: "host2.com",
Username: "user2",
Password: "pass2",
},
},
},
wantErr: false,
},
{
name: "invalid tunnel config",
config: &Config{
Tunnels: map[string]*sshtunnel.Config{
"invalid": {
Type: "INVALID",
Host: "example.com",
Username: "test",
Password: "secret",
},
},
},
wantErr: true,
errMsg: "tunnel 'invalid': unsupported tunnel type: INVALID",
},
{
name: "missing host in tunnel",
config: &Config{
Tunnels: map[string]*sshtunnel.Config{
"nohost": {
Type: "SSH",
Username: "test",
Password: "secret",
},
},
},
wantErr: true,
errMsg: "tunnel 'nohost': host is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.ValidateAndSetDefaults()
if tt.wantErr {
if err == nil {
t.Errorf("ValidateAndSetDefaults() expected error but got none")
return
}
if err.Error() != tt.errMsg {
t.Errorf("ValidateAndSetDefaults() error = %v, want %v", err.Error(), tt.errMsg)
}
return
}
if err != nil {
t.Errorf("ValidateAndSetDefaults() unexpected error = %v", err)
return
}
// Check that connections map is initialized
if tt.config != nil && tt.config.connections == nil {
t.Error("ValidateAndSetDefaults() did not initialize connections map")
}
})
}
}
func TestConfig_GetTunnel(t *testing.T) {
config := &Config{
Tunnels: map[string]*sshtunnel.Config{
"test": {
Type: "SSH",
Host: "example.com",
Username: "test",
Password: "secret",
},
},
}
err := config.ValidateAndSetDefaults()
if err != nil {
t.Fatalf("ValidateAndSetDefaults() failed: %v", err)
}
// Test getting existing tunnel
tunnel1, err := config.GetTunnel("test")
if err != nil {
t.Errorf("GetTunnel() error = %v", err)
return
}
if tunnel1 == nil {
t.Error("GetTunnel() returned nil tunnel")
return
}
// Test getting same tunnel again (should return same instance)
tunnel2, err := config.GetTunnel("test")
if err != nil {
t.Errorf("GetTunnel() second call error = %v", err)
return
}
if tunnel1 != tunnel2 {
t.Error("GetTunnel() should return same instance for same tunnel name")
}
// Test getting non-existent tunnel
_, err = config.GetTunnel("nonexistent")
if err == nil {
t.Error("GetTunnel() expected error for non-existent tunnel")
return
}
expectedErr := "tunnel 'nonexistent' not found in configuration"
if err.Error() != expectedErr {
t.Errorf("GetTunnel() error = %v, want %v", err.Error(), expectedErr)
}
}
func TestConfig_Close(t *testing.T) {
// Test closing config with tunnels
config := &Config{
Tunnels: map[string]*sshtunnel.Config{
"test1": {
Type: "SSH",
Host: "example1.com",
Username: "test",
Password: "secret",
},
"test2": {
Type: "SSH",
Host: "example2.com",
Username: "test",
Password: "secret",
},
},
}
err := config.ValidateAndSetDefaults()
if err != nil {
t.Fatalf("ValidateAndSetDefaults() failed: %v", err)
}
// Create some tunnels
_, err = config.GetTunnel("test1")
if err != nil {
t.Fatalf("GetTunnel() failed: %v", err)
}
_, err = config.GetTunnel("test2")
if err != nil {
t.Fatalf("GetTunnel() failed: %v", err)
}
// Test closing
err = config.Close()
if err != nil {
t.Errorf("Close() returned error: %v", err)
}
// Verify connections map is empty
if len(config.connections) != 0 {
t.Errorf("Close() did not clear connections map, got %d connections", len(config.connections))
}
}
+60 -19
View File
@@ -10,14 +10,20 @@ import (
)
const (
defaultTitle = "Health Dashboard | Gatus"
defaultDescription = "Gatus is an advanced automated status page that lets you monitor your applications and configure alerts to notify you if there's an issue"
defaultHeader = "Gatus"
defaultLogo = ""
defaultLink = ""
defaultCustomCSS = ""
defaultSortBy = "name"
defaultFilterBy = "none"
defaultTitle = "Health Dashboard | Gatus"
defaultDescription = "Gatus is an advanced automated status page that lets you monitor your applications and configure alerts to notify you if there's an issue"
defaultHeader = "Gatus"
defaultDashboardHeading = "Health Dashboard"
defaultDashboardSubheading = "Monitor the health of your endpoints in real-time"
defaultLogo = ""
defaultLink = ""
defaultFavicon = "/favicon.ico"
defaultFavicon16 = "/favicon-16x16.png"
defaultFavicon32 = "/favicon-32x32.png"
defaultCustomCSS = ""
defaultSortBy = "name"
defaultFilterBy = "none"
defaultLoginSubtitle = "System Monitoring Dashboard"
)
var (
@@ -30,17 +36,20 @@ var (
// Config is the configuration for the UI of Gatus
type Config struct {
Title string `yaml:"title,omitempty"` // Title of the page
Description string `yaml:"description,omitempty"` // Meta description of the page
Header string `yaml:"header,omitempty"` // Header is the text at the top of the page
Logo string `yaml:"logo,omitempty"` // Logo to display on the page
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
CustomCSS string `yaml:"custom-css,omitempty"` // Custom CSS to include in the page
DarkMode *bool `yaml:"dark-mode,omitempty"` // DarkMode is a flag to enable dark mode by default
DefaultSortBy string `yaml:"default-sort-by,omitempty"` // DefaultSortBy is the default sort option ('name', 'group', 'health')
DefaultFilterBy string `yaml:"default-filter-by,omitempty"` // DefaultFilterBy is the default filter option ('none', 'failing', 'unstable')
Title string `yaml:"title,omitempty"` // Title of the page
Description string `yaml:"description,omitempty"` // Meta description of the page
DashboardHeading string `yaml:"dashboard-heading,omitempty"` // Dashboard Title between header and endpoints
DashboardSubheading string `yaml:"dashboard-subheading,omitempty"` // Dashboard Description between header and endpoints
Header string `yaml:"header,omitempty"` // Header is the text at the top of the page
Logo string `yaml:"logo,omitempty"` // Logo to display on the page
Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo
Favicon Favicon `yaml:"favicon,omitempty"` // Favourite icon to display in web browser tab or address bar
Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header
CustomCSS string `yaml:"custom-css,omitempty"` // Custom CSS to include in the page
DarkMode *bool `yaml:"dark-mode,omitempty"` // DarkMode is a flag to enable dark mode by default
DefaultSortBy string `yaml:"default-sort-by,omitempty"` // DefaultSortBy is the default sort option ('name', 'group', 'health')
DefaultFilterBy string `yaml:"default-filter-by,omitempty"` // DefaultFilterBy is the default filter option ('none', 'failing', 'unstable')
LoginSubtitle string `yaml:"login-subtitle,omitempty"` // LoginSubtitle is the subtitle displayed on the OIDC login page
//////////////////////////////////////////////
// Non-configurable - used for UI rendering //
//////////////////////////////////////////////
@@ -68,11 +77,19 @@ func (btn *Button) Validate() error {
return nil
}
type Favicon struct {
Default string `yaml:"default,omitempty"` // URL or path to default favourite icon.
Size16x16 string `yaml:"size16x16,omitempty"` // URL or path to favourite icon for 16x16 size.
Size32x32 string `yaml:"size32x32,omitempty"` // URL or path to favourite icon for 32x32 size.
}
// GetDefaultConfig returns a Config struct with the default values
func GetDefaultConfig() *Config {
return &Config{
Title: defaultTitle,
Description: defaultDescription,
DashboardHeading: defaultDashboardHeading,
DashboardSubheading: defaultDashboardSubheading,
Header: defaultHeader,
Logo: defaultLogo,
Link: defaultLink,
@@ -80,7 +97,13 @@ func GetDefaultConfig() *Config {
DarkMode: &defaultDarkMode,
DefaultSortBy: defaultSortBy,
DefaultFilterBy: defaultFilterBy,
LoginSubtitle: defaultLoginSubtitle,
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
Favicon: Favicon{
Default: defaultFavicon,
Size16x16: defaultFavicon16,
Size32x32: defaultFavicon32,
},
}
}
@@ -92,6 +115,12 @@ func (cfg *Config) ValidateAndSetDefaults() error {
if len(cfg.Description) == 0 {
cfg.Description = defaultDescription
}
if len(cfg.DashboardHeading) == 0 {
cfg.DashboardHeading = defaultDashboardHeading
}
if len(cfg.DashboardSubheading) == 0 {
cfg.DashboardSubheading = defaultDashboardSubheading
}
if len(cfg.Header) == 0 {
cfg.Header = defaultHeader
}
@@ -117,6 +146,18 @@ func (cfg *Config) ValidateAndSetDefaults() error {
} else if cfg.DefaultFilterBy != "none" && cfg.DefaultFilterBy != "failing" && cfg.DefaultFilterBy != "unstable" {
return ErrInvalidDefaultFilterBy
}
if len(cfg.LoginSubtitle) == 0 {
cfg.LoginSubtitle = defaultLoginSubtitle
}
if len(cfg.Favicon.Default) == 0 {
cfg.Favicon.Default = defaultFavicon
}
if len(cfg.Favicon.Size16x16) == 0 {
cfg.Favicon.Size16x16 = defaultFavicon16
}
if len(cfg.Favicon.Size32x32) == 0 {
cfg.Favicon.Size32x32 = defaultFavicon32
}
for _, btn := range cfg.Buttons {
if err := btn.Validate(); err != nil {
return err
+132 -25
View File
@@ -7,31 +7,129 @@ import (
)
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
cfg := &Config{
Title: "",
Description: "",
Header: "",
Logo: "",
Link: "",
}
if err := cfg.ValidateAndSetDefaults(); err != nil {
t.Error("expected no error, got", err.Error())
}
if cfg.Title != defaultTitle {
t.Errorf("expected title to be %s, got %s", defaultTitle, cfg.Title)
}
if cfg.Description != defaultDescription {
t.Errorf("expected description to be %s, got %s", defaultDescription, cfg.Description)
}
if cfg.Header != defaultHeader {
t.Errorf("expected header to be %s, got %s", defaultHeader, cfg.Header)
}
if cfg.DefaultSortBy != defaultSortBy {
t.Errorf("expected defaultSortBy to be %s, got %s", defaultSortBy, cfg.DefaultSortBy)
}
if cfg.DefaultFilterBy != defaultFilterBy {
t.Errorf("expected defaultFilterBy to be %s, got %s", defaultFilterBy, cfg.DefaultFilterBy)
}
t.Run("empty-config", func(t *testing.T) {
cfg := &Config{
Title: "",
Description: "",
DashboardHeading: "",
DashboardSubheading: "",
Header: "",
Logo: "",
Link: "",
}
if err := cfg.ValidateAndSetDefaults(); err != nil {
t.Error("expected no error, got", err.Error())
}
if cfg.Title != defaultTitle {
t.Errorf("expected title to be %s, got %s", defaultTitle, cfg.Title)
}
if cfg.Description != defaultDescription {
t.Errorf("expected description to be %s, got %s", defaultDescription, cfg.Description)
}
if cfg.DashboardHeading != defaultDashboardHeading {
t.Errorf("expected DashboardHeading to be %s, got %s", defaultDashboardHeading, cfg.DashboardHeading)
}
if cfg.DashboardSubheading != defaultDashboardSubheading {
t.Errorf("expected DashboardSubheading to be %s, got %s", defaultDashboardSubheading, cfg.DashboardSubheading)
}
if cfg.Header != defaultHeader {
t.Errorf("expected header to be %s, got %s", defaultHeader, cfg.Header)
}
if cfg.DefaultSortBy != defaultSortBy {
t.Errorf("expected defaultSortBy to be %s, got %s", defaultSortBy, cfg.DefaultSortBy)
}
if cfg.DefaultFilterBy != defaultFilterBy {
t.Errorf("expected defaultFilterBy to be %s, got %s", defaultFilterBy, cfg.DefaultFilterBy)
}
if cfg.Favicon.Default != defaultFavicon {
t.Errorf("expected favicon to be %s, got %s", defaultFavicon, cfg.Favicon.Default)
}
if cfg.Favicon.Size16x16 != defaultFavicon16 {
t.Errorf("expected favicon to be %s, got %s", defaultFavicon16, cfg.Favicon.Size16x16)
}
if cfg.Favicon.Size32x32 != defaultFavicon32 {
t.Errorf("expected favicon to be %s, got %s", defaultFavicon32, cfg.Favicon.Size32x32)
}
if cfg.LoginSubtitle != defaultLoginSubtitle {
t.Errorf("expected LoginSubtitle to be %s, got %s", defaultLoginSubtitle, cfg.LoginSubtitle)
}
})
t.Run("custom-values", func(t *testing.T) {
cfg := &Config{
Title: "Custom Title",
Description: "Custom Description",
DashboardHeading: "Production Status",
DashboardSubheading: "Monitor all production endpoints",
Header: "My Company",
Logo: "https://example.com/logo.png",
Link: "https://example.com",
DefaultSortBy: "health",
DefaultFilterBy: "failing",
LoginSubtitle: "Welcome",
}
if err := cfg.ValidateAndSetDefaults(); err != nil {
t.Error("expected no error, got", err.Error())
}
if cfg.Title != "Custom Title" {
t.Errorf("expected title to be preserved, got %s", cfg.Title)
}
if cfg.Description != "Custom Description" {
t.Errorf("expected description to be preserved, got %s", cfg.Description)
}
if cfg.DashboardHeading != "Production Status" {
t.Errorf("expected DashboardHeading to be preserved, got %s", cfg.DashboardHeading)
}
if cfg.DashboardSubheading != "Monitor all production endpoints" {
t.Errorf("expected DashboardSubheading to be preserved, got %s", cfg.DashboardSubheading)
}
if cfg.Header != "My Company" {
t.Errorf("expected header to be preserved, got %s", cfg.Header)
}
if cfg.Logo != "https://example.com/logo.png" {
t.Errorf("expected logo to be preserved, got %s", cfg.Logo)
}
if cfg.Link != "https://example.com" {
t.Errorf("expected link to be preserved, got %s", cfg.Link)
}
if cfg.DefaultSortBy != "health" {
t.Errorf("expected defaultSortBy to be preserved, got %s", cfg.DefaultSortBy)
}
if cfg.DefaultFilterBy != "failing" {
t.Errorf("expected defaultFilterBy to be preserved, got %s", cfg.DefaultFilterBy)
}
if cfg.LoginSubtitle != "Welcome" {
t.Errorf("expected LoginSubtitle to be preserved, got %s", cfg.LoginSubtitle)
}
})
t.Run("partial-custom-values", func(t *testing.T) {
cfg := &Config{
Title: "Custom Title",
DashboardHeading: "My Dashboard",
Header: "",
DashboardSubheading: "",
}
if err := cfg.ValidateAndSetDefaults(); err != nil {
t.Error("expected no error, got", err.Error())
}
if cfg.Title != "Custom Title" {
t.Errorf("expected custom title to be preserved, got %s", cfg.Title)
}
if cfg.DashboardHeading != "My Dashboard" {
t.Errorf("expected custom DashboardHeading to be preserved, got %s", cfg.DashboardHeading)
}
if cfg.DashboardSubheading != defaultDashboardSubheading {
t.Errorf("expected DashboardSubheading to use default, got %s", cfg.DashboardSubheading)
}
if cfg.Header != defaultHeader {
t.Errorf("expected header to use default, got %s", cfg.Header)
}
if cfg.Description != defaultDescription {
t.Errorf("expected description to use default, got %s", cfg.Description)
}
if cfg.LoginSubtitle != defaultLoginSubtitle {
t.Errorf("expected LoginSubtitle to use default, got %s", cfg.LoginSubtitle)
}
})
}
func TestButton_Validate(t *testing.T) {
@@ -78,6 +176,12 @@ func TestGetDefaultConfig(t *testing.T) {
if defaultConfig.Title != defaultTitle {
t.Error("expected GetDefaultConfig() to return defaultTitle, got", defaultConfig.Title)
}
if defaultConfig.DashboardHeading != defaultDashboardHeading {
t.Error("expected GetDefaultConfig() to return defaultDashboardHeading, got", defaultConfig.DashboardHeading)
}
if defaultConfig.DashboardSubheading != defaultDashboardSubheading {
t.Error("expected GetDefaultConfig() to return defaultDashboardSubheading, got", defaultConfig.DashboardSubheading)
}
if defaultConfig.Logo != defaultLogo {
t.Error("expected GetDefaultConfig() to return defaultLogo, got", defaultConfig.Logo)
}
@@ -87,6 +191,9 @@ func TestGetDefaultConfig(t *testing.T) {
if defaultConfig.DefaultFilterBy != defaultFilterBy {
t.Error("expected GetDefaultConfig() to return defaultFilterBy, got", defaultConfig.DefaultFilterBy)
}
if defaultConfig.LoginSubtitle != defaultLoginSubtitle {
t.Error("expected GetDefaultConfig() to return defaultLoginSubtitle, got", defaultConfig.LoginSubtitle)
}
}
func TestConfig_ValidateAndSetDefaults_DefaultSortBy(t *testing.T) {
-10
View File
@@ -4,13 +4,3 @@ package config
func toPtr[T any](value T) *T {
return &value
}
// contains checks if a key exists in the slice
func contains[T comparable](slice []T, key T) bool {
for _, item := range slice {
if item == key {
return true
}
}
return false
}
+67 -55
View File
@@ -1,91 +1,103 @@
module github.com/TwiN/gatus/v5
go 1.24.4
toolchain go1.24.7
go 1.26.3
require (
code.gitea.io/sdk/gitea v0.21.0
code.gitea.io/sdk/gitea v0.25.1
github.com/TwiN/deepmerge v0.2.2
github.com/TwiN/g8/v2 v2.0.0
github.com/TwiN/gocache/v2 v2.4.0
github.com/TwiN/health v1.6.0
github.com/TwiN/logr v0.3.1
github.com/TwiN/whois v1.1.11
github.com/aws/aws-sdk-go v1.55.8
github.com/coreos/go-oidc/v3 v3.14.1
github.com/gofiber/fiber/v2 v2.52.8
github.com/TwiN/whois v1.3.0
github.com/aws/aws-sdk-go-v2 v1.41.7
github.com/aws/aws-sdk-go-v2/config v1.32.17
github.com/aws/aws-sdk-go-v2/credentials v1.19.16
github.com/aws/aws-sdk-go-v2/service/ses v1.34.24
github.com/coreos/go-oidc/v3 v3.18.0
github.com/gofiber/fiber/v2 v2.52.13
github.com/google/go-github/v48 v48.2.0
github.com/google/uuid v1.6.0
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2
github.com/lib/pq v1.10.9
github.com/miekg/dns v1.1.68
github.com/prometheus-community/pro-bing v0.7.0
github.com/prometheus/client_golang v1.23.0
github.com/gorilla/websocket v1.5.3
github.com/ishidawataru/sctp v0.0.0-20251114114122-19ddcbc6aae2
github.com/lib/pq v1.12.3
github.com/miekg/dns v1.1.72
github.com/prometheus-community/pro-bing v0.8.0
github.com/prometheus/client_golang v1.23.2
github.com/registrobr/rdap v1.1.8
github.com/valyala/fasthttp v1.64.0
github.com/valyala/fasthttp v1.71.0
github.com/wcharczuk/go-chart/v2 v2.1.2
golang.org/x/crypto v0.40.0
golang.org/x/net v0.42.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.17.0
google.golang.org/api v0.242.0
golang.org/x/crypto v0.55.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.22.0
google.golang.org/api v0.280.0
google.golang.org/grpc v1.81.1
gopkg.in/mail.v2 v2.3.1
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.38.2
modernc.org/sqlite v1.59.0
)
require (
cloud.google.com/go/auth v0.16.2 // indirect
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.7.0 // indirect
github.com/42wim/httpsig v1.2.2 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/42wim/httpsig v1.2.4 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
github.com/aws/smithy-go v1.25.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.14.2 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/hashicorp/go-version v1.9.0 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.65.0 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/grpc v1.73.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/image v0.40.0 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/tools v0.48.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/memory v1.12.1 // indirect
)
+166 -140
View File
@@ -1,13 +1,13 @@
cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4=
cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA=
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
code.gitea.io/sdk/gitea v0.21.0 h1:69n6oz6kEVHRo1+APQQyizkhrZrLsTLXey9142pfkD4=
code.gitea.io/sdk/gitea v0.21.0/go.mod h1:tnBjVhuKJCn8ibdyyhvUyxrR1Ca2KHEoTWoukNhXQPA=
github.com/42wim/httpsig v1.2.2 h1:ofAYoHUNs/MJOLqQ8hIxeyz2QxOz8qdSVvp3PX/oPgA=
github.com/42wim/httpsig v1.2.2/go.mod h1:P/UYo7ytNBFwc+dg35IubuAUIs8zj5zzFIgUCEl55WY=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
code.gitea.io/sdk/gitea v0.25.1 h1:yywxWwoV+SdjHtbC6unBiXojWdZOtoHuGhEazEXeWuE=
code.gitea.io/sdk/gitea v0.25.1/go.mod h1:uDFWYBU8dgZsgOHwe6C/6olxvf8FHguNB3wW1i83fgg=
github.com/42wim/httpsig v1.2.4 h1:mI5bH0nm4xn7K18fo1K3okNDRq8CCJ0KbBYWyA6r8lU=
github.com/42wim/httpsig v1.2.4/go.mod h1:yKsYfSyTBEohkPik224QPFylmzEBtda/kjyIAJjh3ps=
github.com/TwiN/deepmerge v0.2.2 h1:FUG9QMIYg/j2aQyPPhA3XTFJwXSNHI/swaR4Lbyxwg4=
github.com/TwiN/deepmerge v0.2.2/go.mod h1:4OHvjV3pPNJCJZBHswYAwk6rxiD8h8YZ+9cPo7nu4oI=
github.com/TwiN/g8/v2 v2.0.0 h1:+hwIbRLMhDd2iwHzkZUPp2FkX7yTx8ddYOnS91HkDqQ=
@@ -18,21 +18,50 @@ github.com/TwiN/health v1.6.0 h1:L2ks575JhRgQqWWOfKjw9B0ec172hx7GdToqkYUycQM=
github.com/TwiN/health v1.6.0/go.mod h1:Z6TszwQPMvtSiVx1QMidVRgvVr4KZGfiwqcD7/Z+3iw=
github.com/TwiN/logr v0.3.1 h1:CfTKA83jUmsAoxqrr3p4JxEkqXOBnEE9/f35L5MODy4=
github.com/TwiN/logr v0.3.1/go.mod h1:BZgZFYq6fQdU3KtR8qYato3zUEw53yQDaIuujHb55Jw=
github.com/TwiN/whois v1.1.11 h1:lYiYgPRSQ3kH8sQfgHcBY/uNSGGvWPRikEjn+LJZ9+Q=
github.com/TwiN/whois v1.1.11/go.mod h1:TjipCMpJRAJYKmtz/rXQBU6UGxMh6bk8SHazu7OMnQE=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/TwiN/whois v1.3.0 h1:V2+IUh5OGim8F3axTOMSlVmxNRaBrgpyiv5U0Dvbt5I=
github.com/TwiN/whois v1.3.0/go.mod h1:TjipCMpJRAJYKmtz/rXQBU6UGxMh6bk8SHazu7OMnQE=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b h1:uUXgbcPDK3KpW29o4iy7GtuappbWT0l5NaMo9H9pJDw=
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
github.com/aws/aws-sdk-go-v2/service/ses v1.34.24 h1:ZPCoU087iv2IWJKNQyN+MYOqZAM9lEVevS9MHWGG4wI=
github.com/aws/aws-sdk-go-v2/service/ses v1.34.24/go.mod h1:4T8OWyQ9nkdUrcqHHJkyibP5TfcWDTe4kWL40tiCOcs=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
@@ -43,119 +72,115 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gofiber/fiber/v2 v2.52.8 h1:xl4jJQ0BV5EJTA2aWiKw/VddRpHrKeZLF0QPUxqn0x4=
github.com/gofiber/fiber/v2 v2.52.8/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJbCbk=
github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v48 v48.2.0 h1:68puzySE6WqUY9KWmpOsDEQfDZsso98rT6pZcz9HqcE=
github.com/google/go-github/v48 v48.2.0/go.mod h1:dDlehKBDo850ZPvCTK0sEqTCVWcrGl2LcDiajkYi89Y=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2 h1:i2fYnDurfLlJH8AyyMOnkLHnHeP8Ff/DDpuZA/D3bPo=
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2/go.mod h1:co9pwDoBCm1kGxawmb4sPq0cSIOOWNPT4KnHotMP1Zg=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas=
github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA=
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/ishidawataru/sctp v0.0.0-20251114114122-19ddcbc6aae2 h1:36qep4gxKs+JgeHGWeQ040RyZdt9kQlLglL1rFVn/oQ=
github.com/ishidawataru/sctp v0.0.0-20251114114122-19ddcbc6aae2/go.mod h1:co9pwDoBCm1kGxawmb4sPq0cSIOOWNPT4KnHotMP1Zg=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA=
github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE=
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc=
github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/registrobr/rdap v1.1.8 h1:7egYAM8MsuencdP9mvF/892f8OjXvUFSyp5cT1Lg45U=
github.com/registrobr/rdap v1.1.8/go.mod h1:VY2DVrpsJpUfy9gj2QvurGymCgZV11/11cxQz5CxO+w=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.64.0 h1:QBygLLQmiAyiXuRhthf0tuRkqAFcrC42dckN2S+N3og=
github.com/valyala/fasthttp v1.64.0/go.mod h1:dGmFxwkWXSK0NbOSJuF7AMVzU+lkHz0wQVvVITv2UQA=
github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E=
github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
@@ -163,19 +188,18 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -185,33 +209,31 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -220,8 +242,8 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@@ -231,28 +253,33 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.242.0 h1:7Lnb1nfnpvbkCiZek6IXKdJ0MFuAZNAJKQfA1ws62xg=
google.golang.org/api v0.242.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.280.0 h1:F4OfEHZhZh6a7uTufJAXXVd/2TQ8EjM4vZH+jX/vFYk=
google.golang.org/api v0.280.0/go.mod h1:oGKmPZRDoD3vdkf6MA7F4VNkR1rxCiuaPSkhsf3EolU=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94 h1:eZCjr/aAF8c5ccm5pb6T4EXgIei5MlAAPWPJk+5ArfY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -260,33 +287,32 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/libc v1.75.7 h1:o3DTP9/0p9pKmY2WCKQaySW6wIiZhNM7wc2lUoyhfew=
modernc.org/libc v1.75.7/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+1 -1
View File
@@ -87,7 +87,7 @@ func extractValue(currentKey string, value interface{}) interface{} {
}
}
arrayIndex, err := strconv.Atoi(index)
if err != nil {
if err != nil || arrayIndex < 0 {
return nil
}
currentKeyWithoutIndex := currentKey[:startOfBracket]
+32
View File
@@ -174,6 +174,38 @@ func TestEval(t *testing.T) {
ExpectedOutputLength: 18,
ExpectedError: false,
},
{
Name: "negative-index-on-keyed-array",
Path: "data[-1]",
Data: `{"data": [1, 2, 3]}`,
ExpectedOutput: "",
ExpectedOutputLength: 0,
ExpectedError: true,
},
{
Name: "negative-index-on-root-array",
Path: "[-1]",
Data: `[1, 2, 3]`,
ExpectedOutput: "",
ExpectedOutputLength: 0,
ExpectedError: true,
},
{
Name: "negative-index-followed-by-key",
Path: "data[-1].name",
Data: `{"data": [{"name": "value"}]}`,
ExpectedOutput: "",
ExpectedOutputLength: 0,
ExpectedError: true,
},
{
Name: "negative-index-nested-array",
Path: "data[0][-1]",
Data: `{"data": [[1, 2, 3]]}`,
ExpectedOutput: "",
ExpectedOutputLength: 0,
ExpectedError: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
+36
View File
@@ -59,6 +59,7 @@ func stop(cfg *config.Config) {
watchdog.Shutdown(cfg)
controller.Shutdown()
metrics.UnregisterPrometheusMetrics()
closeTunnels(cfg)
}
func save() {
@@ -182,11 +183,46 @@ func initializeStorage(cfg *config.Config) {
}
}
}
// Load persisted triggered alerts for suite endpoints
for _, suite := range cfg.Suites {
for _, ep := range suite.Endpoints {
var checksums []string
for _, alert := range ep.Alerts {
if alert.IsEnabled() {
checksums = append(checksums, alert.Checksum())
}
}
numberOfTriggeredAlertsDeleted := store.Get().DeleteAllTriggeredAlertsNotInChecksumsByEndpoint(ep, checksums)
if numberOfTriggeredAlertsDeleted > 0 {
logr.Debugf("[main.initializeStorage] Deleted %d triggered alerts for suite endpoint with key=%s because their configurations have been changed or deleted", numberOfTriggeredAlertsDeleted, ep.Key())
}
for _, alert := range ep.Alerts {
exists, resolveKey, numberOfSuccessesInARow, err := store.Get().GetTriggeredEndpointAlert(ep, alert)
if err != nil {
logr.Errorf("[main.initializeStorage] Failed to get triggered alert for suite endpoint with key=%s: %s", ep.Key(), err.Error())
continue
}
if exists {
alert.Triggered, alert.ResolveKey = true, resolveKey
ep.NumberOfSuccessesInARow, ep.NumberOfFailuresInARow = numberOfSuccessesInARow, alert.FailureThreshold
numberOfPersistedTriggeredAlertsLoaded++
}
}
}
}
if numberOfPersistedTriggeredAlertsLoaded > 0 {
logr.Infof("[main.initializeStorage] Loaded %d persisted triggered alerts", numberOfPersistedTriggeredAlertsLoaded)
}
}
func closeTunnels(cfg *config.Config) {
if cfg.Tunneling != nil {
if err := cfg.Tunneling.Close(); err != nil {
logr.Errorf("[main.closeTunnels] Error closing SSH tunnels: %v", err)
}
}
}
func listenToConfigurationFileChanges(cfg *config.Config) {
for {
time.Sleep(30 * time.Second)
+14
View File
@@ -17,6 +17,7 @@ var (
resultConnectedTotal *prometheus.CounterVec
resultCodeTotal *prometheus.CounterVec
resultCertificateExpirationSeconds *prometheus.GaugeVec
resultDomainExpirationSeconds *prometheus.GaugeVec
resultEndpointSuccess *prometheus.GaugeVec
// Suite metrics
@@ -51,6 +52,9 @@ func UnregisterPrometheusMetrics() {
if resultCertificateExpirationSeconds != nil {
currentRegisterer.Unregister(resultCertificateExpirationSeconds)
}
if resultDomainExpirationSeconds != nil {
currentRegisterer.Unregister(resultDomainExpirationSeconds)
}
if resultEndpointSuccess != nil {
currentRegisterer.Unregister(resultEndpointSuccess)
}
@@ -119,6 +123,13 @@ func InitializePrometheusMetrics(cfg *config.Config, reg prometheus.Registerer)
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
reg.MustRegister(resultCertificateExpirationSeconds)
resultDomainExpirationSeconds = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Name: "results_domain_expiration_seconds",
Help: "Number of seconds until the domain expires",
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
reg.MustRegister(resultDomainExpirationSeconds)
resultEndpointSuccess = prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Name: "results_endpoint_success",
@@ -178,6 +189,9 @@ func PublishMetricsForEndpoint(ep *endpoint.Endpoint, result *endpoint.Result, e
if result.CertificateExpiration != 0 {
resultCertificateExpirationSeconds.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(result.CertificateExpiration.Seconds())
}
if result.DomainExpiration != 0 {
resultDomainExpirationSeconds.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(result.DomainExpiration.Seconds())
}
if result.Success {
resultEndpointSuccess.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(1)
} else {
+13
View File
@@ -48,6 +48,9 @@ func TestInitializePrometheusMetrics(t *testing.T) {
if resultCertificateExpirationSeconds == nil {
t.Error("resultCertificateExpirationSeconds metric not initialized")
}
if resultDomainExpirationSeconds == nil {
t.Error("resultDomainExpirationSeconds metric not initialized")
}
if resultEndpointSuccess == nil {
t.Error("resultEndpointSuccess metric not initialized")
}
@@ -120,9 +123,11 @@ func TestPublishMetricsForEndpoint(t *testing.T) {
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[STATUS] == 200", Success: true},
{Condition: "[CERTIFICATE_EXPIRATION] > 48h", Success: true},
{Condition: "[DOMAIN_EXPIRATION] > 24h", Success: true},
},
Success: true,
CertificateExpiration: 49 * time.Hour,
DomainExpiration: 25 * time.Hour,
}, []string{})
err := testutil.GatherAndCompare(reg, bytes.NewBufferString(`
# HELP gatus_results_code_total Total number of results by code
@@ -140,6 +145,9 @@ gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name=
# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires
# TYPE gatus_results_certificate_expiration_seconds gauge
gatus_results_certificate_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 176400
# HELP gatus_results_domain_expiration_seconds Number of seconds until the domain expires
# TYPE gatus_results_domain_expiration_seconds gauge
gatus_results_domain_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 90000
# HELP gatus_results_endpoint_success Displays whether or not the endpoint was a success
# TYPE gatus_results_endpoint_success gauge
gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 1
@@ -154,9 +162,11 @@ gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[STATUS] == 200", Success: true},
{Condition: "[CERTIFICATE_EXPIRATION] > 47h", Success: false},
{Condition: "[DOMAIN_EXPIRATION] > 24h", Success: true},
},
Success: false,
CertificateExpiration: 47 * time.Hour,
DomainExpiration: 24 * time.Hour,
}, []string{})
err = testutil.GatherAndCompare(reg, bytes.NewBufferString(`
# HELP gatus_results_code_total Total number of results by code
@@ -175,6 +185,9 @@ gatus_results_total{group="http-ep-group",key="http-ep-group_http-ep-name",name=
# HELP gatus_results_certificate_expiration_seconds Number of seconds until the certificate expires
# TYPE gatus_results_certificate_expiration_seconds gauge
gatus_results_certificate_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 169200
# HELP gatus_results_domain_expiration_seconds Number of seconds until the domain expires
# TYPE gatus_results_domain_expiration_seconds gauge
gatus_results_domain_expiration_seconds{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 86400
# HELP gatus_results_endpoint_success Displays whether or not the endpoint was a success
# TYPE gatus_results_endpoint_success gauge
gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-name",name="http-ep-name",type="HTTP"} 0
+1 -1
View File
@@ -28,7 +28,7 @@ type Config struct {
// ValidateAndSetDefaults returns whether the security configuration is valid or not and sets default values.
func (c *Config) ValidateAndSetDefaults() bool {
return (c.Basic != nil && c.Basic.isValid()) || (c.OIDC != nil && c.OIDC.ValidateAndSetDefaults())
return (c.Basic == nil || c.Basic.isValid()) && (c.OIDC == nil || c.OIDC.ValidateAndSetDefaults())
}
// RegisterHandlers registers all handlers required based on the security configuration
+75 -5
View File
@@ -10,12 +10,82 @@ import (
)
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
c := &Config{
Basic: nil,
OIDC: nil,
validBasicConfig := &BasicConfig{
Username: "test",
PasswordBcryptHashBase64Encoded: "somevalue",
}
if c.ValidateAndSetDefaults() {
t.Error("expected empty config to be valid")
validOIDCConfig := &OIDCConfig{
IssuerURL: "testurl",
RedirectURL: "testredirecturl/authorization-code/callback",
ClientID: "testid",
ClientSecret: "testsecret",
Scopes: []string{"testscope"},
}
type Scenario struct {
Name string
Config *Config
ExpectValid bool
}
scenarios := []Scenario{
{
Name: "empty",
Config: &Config{
Basic: nil,
OIDC: nil,
},
ExpectValid: true,
},
{
Name: "empty-basic",
Config: &Config{
Basic: &BasicConfig{},
OIDC: nil,
},
ExpectValid: false,
},
{
Name: "empty-oidc",
Config: &Config{
Basic: nil,
OIDC: &OIDCConfig{},
},
ExpectValid: false,
},
{
Name: "valid-basic-only",
Config: &Config{
Basic: validBasicConfig,
OIDC: nil,
},
ExpectValid: true,
},
{
Name: "valid-oidc-only",
Config: &Config{
Basic: nil,
OIDC: validOIDCConfig,
},
ExpectValid: true,
},
{
Name: "valid-basic-and-oidc",
Config: &Config{
Basic: validBasicConfig,
OIDC: validOIDCConfig,
},
ExpectValid: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
isValid := scenario.Config.ValidateAndSetDefaults()
if isValid && !scenario.ExpectValid {
t.Errorf("scenario %s: expected config to be invalid", scenario.Name)
} else if !isValid && scenario.ExpectValid {
t.Errorf("scenario %s: expected config to be valid", scenario.Name)
}
})
}
}
+2 -7
View File
@@ -1,6 +1,7 @@
package memory
import (
"slices"
"sort"
"sync"
"time"
@@ -237,13 +238,7 @@ func (s *Store) InsertSuiteResult(su *suite.Suite, result *suite.Result) error {
func (s *Store) DeleteAllEndpointStatusesNotInKeys(keys []string) int {
var keysToDelete []string
for _, existingKey := range s.endpointCache.GetKeysByPattern("*", 0) {
shouldDelete := true
for _, k := range keys {
if existingKey == k {
shouldDelete = false
break
}
}
shouldDelete := !slices.Contains(keys, existingKey)
if shouldDelete {
keysToDelete = append(keysToDelete, existingKey)
}

Some files were not shown because too many files have changed in this diff Show More