83 Commits

Author SHA1 Message Date
Iain Sproat 20b5b7fdc3 chore(deps): bump go dependencies (#37)
* chore(deps): bump go dependencies

* chore(deps): bump version of git in Dockerfile builder

* downgrade to previous release candidate of git
2024-06-18 08:54:15 +01:00
Iain Sproat f79a6754f5 chore(circleci): update remote docker to prevent 'brownout' (#36)
* chore(circleci): update remote docker to prevent 'brownout'

* Update packages
2024-05-28 16:13:24 +01:00
Iain Sproat c2457d9061 chore(deps): upgrade go mod deps (#35)
* chore(deps): upgrade go mod deps
* go mod tidy
2024-05-02 16:34:55 +01:00
Iain Sproat 6ff13d331f chore(deps): go get update (#34)
* chore(deps): go get update
* go mod tidy
2024-04-03 16:34:59 +01:00
Iain Sproat c701667bf2 chore(circleci): bump docker image versions (#32) 2024-02-23 12:27:04 +00:00
Iain Sproat faa6956b29 chore(helm chart): add priority class name (#31) 2024-02-02 16:31:01 +00:00
Iain Sproat a328544e11 chore(deps): bump go dependencies (#30) 2024-01-29 11:58:00 +00:00
Iain Sproat 035505cb0e chore(deps): bump dependencies (#29) 2024-01-03 19:03:24 +00:00
Iain Sproat 5de0c11f44 chore(deps): bump dependencies (#28)
* chore(deps): bumps numerous dependencies to latest versions
- addresses snyk vulnerabilities
* Bump dockerfile packages
2023-08-08 07:06:14 +01:00
Iain Sproat e7fcdec23d chore(deps): bump all dependencies (#27)
* chore(deps): bumps all go dependencies to latest as of 2023-03-16
* chore(deps): bump version of git to 2.38.4-r1
2023-03-16 18:42:25 +00:00
Iain Sproat 3cf3536256 chore(deps): bump all dependencies (#26)
* chore(deps): bump all dependencies
* chore(docker): bump dependencies
2023-02-07 08:49:24 +00:00
Iain Sproat 1ee293fe6a chore(ssh key): ssh key for helm publishing must have write permissions (#25) 2023-01-05 16:45:37 +00:00
Iain Sproat ad387e2a4d Update SSH Key fingerprint to use Deploy key configured by CircleCI (#24)
* Update SSH Key fingerprint to use Deploy key configured by CircleCI
- removes unused orb reference
- removes unneeded version info

* Bump alpine dependencies
2023-01-05 16:25:18 +00:00
Iain Sproat d4de1e7f3d Copy the files in the build directory, not the directory (#22) 2022-11-25 10:55:42 +00:00
Iain Sproat c67919d0a2 CircleCI was placing tagged versions in incubator instead of stable (#20) 2022-11-25 10:21:02 +00:00
Iain Sproat 62bc6ef79e CircleCI should not build and build-and-publish for tags (#19)
- it should only build-and-publish
2022-11-25 09:55:28 +00:00
Iain Sproat 3b47e55e2a CI(circleci): Adds SSH key fingerprint (#18) 2022-11-24 17:26:36 +00:00
Iain Sproat a2555fcff1 ci(circleci): DOCKER_REG_URL env variable should be optional (#17)
Co-authored-by: abc <i@example.org>
2022-11-24 16:37:45 +00:00
Iain Sproat c6dd80140e Circleci should publish helm chart to gh-pages (#15)
* Update screenshot to match current output
* Add job to Helm package and push to gh-pages branch
* Update .gitignore
* Add job to get version, semver2 compatible
* Only helm publish on main branch
* Add build arg to Dockerfile build command to set application version number
2022-11-24 16:28:00 +00:00
Iain Sproat f2872d6dea Configurable logging levels (#16)
* Update screenshot to match current output
* Log level is configurable
* Alert name is provided in log line, where available.
* Capitalise Discord, check err in integration test, and other syntax issues
* Go formatting syntax fixes
* Log the common alert name or group alert name, in that precedence
2022-11-21 21:04:05 +00:00
Iain Sproat 6c6eef2854 Tab for indentation 2022-11-18 11:33:12 +00:00
Iain Sproat 2d7a7522e0 Summary and environment type are hoisted into discord embed field name 2022-11-18 10:57:09 +00:00
Iain Sproat 95422cf410 Indent annotations and labels within Discord embed 2022-11-18 10:20:38 +00:00
Iain Sproat c1caf51eb9 CircleCI only publishes if pre-commit has passed 2022-11-18 10:12:41 +00:00
Iain Sproat b7fc536214 Sort labels and annotations alphabetically 2022-11-18 09:56:31 +00:00
Iain Sproat 838a1d5fdf Dump all annotations and labels into Discord embed for each alert 2022-11-17 16:54:53 +00:00
Iain Sproat 5a070fed0e Attributes are now included in Discord embed field 2022-11-16 10:03:29 +00:00
Iain Sproat 5193686610 Helm test should run against the readiness endpoint for now. (#10) 2022-11-14 11:56:32 +00:00
Iain Sproat 0ee0afdf99 Update Helm Chart and README (#8) 2022-11-14 10:42:05 +00:00
Iain Sproat b4a48fd928 Refactor and productionise (#6)
* Adds development instructions to README
* Replaces deprecated io/ioutil with io package
* Catch all thrown errors and handle them.
  - not catching errors could result in unknown behaviour
* Fix gofmt formatting issues
* Refactor to allow http client to be provided
  - default client does not have timeout etc., we may instead wish to provide a custom http client.
* Refactor to something closer to the standard go layout
  - separates alert forwarder into separate package to allow for testing/reuse
* Split out types, and split Discord client into its own package
* Renaming of symbols for readability
  - no need to abbreviate words in modern IDEs
* remove go-vet hook as it is broken when go files are not in root directory
* unit tests for ~90% coverage
* Update picture in README
* Return error status codes to caller in event of error from Discord
* Remove panic, replace with error status code response and log message. Improve the status codes that are returned to provide more context on what has occurred.
* Graceful shutdown of server, including signal handling
* Integration tests
  - mocks Discord server
  - tests Happy case and a couple of unhappy cases
  - most edge conditions are otherwise tested in unit tests
* CheckWebHook should return errors instead of logging
  - additional checks in tests for nil objects
  - attempt to solve integration test pollution by using different port numbers to prevent potential collision
  - Temporarily comment out test causing interaction pollution with other tests
* structured logging
* feat(exponential backoff): Added to Discord client
* Serve prometheus metrics
  - Monitoring for the discord client
* adds correlation ID to logging
* refactors the mock http client to allow it to work with instrumentation for monitoring
* Helm chart service monitor
* Improved flag and env var parsing
* Application version passed in via build args
* Order of precedence of configuration configuration file<environment variable<command line
* Mounts secret to file instead of in environment variable
* Adds build tag to integration tests to prevent them being run as a unit test
2022-11-14 09:46:27 +00:00
Iain Sproat a15481f294 Updates README (#3)
* Updates README
  * Update helm instructions.
* Generates README for helm chart
* Adds a schema.json file for helm chart
2022-11-07 22:43:17 +00:00
Iain Sproat 82e9275166 helm chart (#5)
* Helm chart to deploy to kubernetes, including optional CiliumNetworkPolicy
* Support liveness and readiness probes from Kubernetes
* Pre-commit checks for prettier and check-yaml should ignore helm chart templates
2022-11-07 21:10:35 +00:00
Iain Sproat 8f5aef469e Enable pre-commit step in CircleCI (#4) 2022-11-07 20:00:59 +00:00
Iain Sproat 89379c4700 Fixes broken references in CircleCI (#2)
* Fixes broken references in CircleCI
2022-11-07 18:07:02 +00:00
Iain Sproat efb5ce6449 Merge pull request #1 from specklesystems/iain/circleci
CI(pre-commit): adds circleci and pre-commit
2022-11-07 17:57:04 +00:00
Iain Sproat 9826fef9e7 CircleCi workflow configured
- pre-commit job temporarily commented out until pre-commit-runner supports go
2022-11-07 17:35:27 +00:00
Iain Sproat 4ce14be96c Bump go to 1.19 2022-11-07 17:34:19 +00:00
Iain Sproat 2c0628507c ci(pre-commit): Adds pre-commit script to verify Dockerfile & Go 2022-11-07 17:31:34 +00:00
Ben Cox 89ef841a7e Merge pull request #31 from akshtshrma24/fixParser
Account for discord webhook IDs with length 19
2022-08-12 17:15:22 +01:00
akshtshrma24 16dcdf656d fixed parser 2022-08-06 23:10:10 -07:00
Ben Cox ceceb475c7 Add LICENCE file
Closes #25
2021-09-28 14:59:16 +01:00
Ben Cartwright-Cox 2bfb007781 Ensure if listening fails, exit code is not 0
This fixes #23

Since if listening failes on http.ListenAndServe then it would
simply hit the end of main() and thus exit with the status code
of 0, systemd or other task orchestration systems may not restart
on the count that the program did not technically signal it failed.
2021-07-17 20:03:28 +02:00
Ben Cox 3b8af1f970 Merge pull request #19 from karugaru/refactoring
Refactoring main.go
2021-03-10 18:20:54 +00:00
nerves_dev a47f4146f5 Refactoring: Split RawPromAlert into func 2021-03-08 16:19:27 +09:00
nerves_dev 7a32366d51 Refactoring: Split send alert messages into func 2021-03-08 16:13:51 +09:00
nerves_dev 49e3076d4e Refactoring: Split webhook url check into func 2021-03-08 16:08:15 +09:00
nerves_dev b504e7f09c Refactoring: Move param definitions to var section 2021-03-08 16:05:28 +09:00
nerves_dev e8c1bd3569 Refactoring: Color code to hexadecimal notation 2021-03-08 16:03:36 +09:00
Ben Cartwright-Cox df21563f32 Explain better how this fits into the prometheus stack 2021-02-28 18:20:51 +00:00
Ben Cartwright-Cox d9ee4ef581 Detect and notify users when they try to use this _as_ alertman
Since people keep doing it.

See: #18
See: #13
See: #14

and a handful of discord DM's
2021-02-28 18:10:53 +00:00
Ben Cartwright-Cox e9c2c3801e Fiddle with go.mod to make dockerhub build it?
idk it stopped working with:

---> Running in 74be85315501
1mgo: cannot find main module, but found .git/config in /go/src/mypackage/myapp
to create a module there, run:
go mod init

Removing intermediate container 74be85315501
The command '/bin/sh -c go get -d -v' returned a non-zero code: 1

---
2021-02-25 20:13:59 +00:00
Ben Cartwright-Cox 5fac7bac9d Improve error handling and pointers for better debugging.
This is to assist debugging #18 - But should be harmless to everyone
else
2021-02-25 19:54:07 +00:00
Ben Cox c27d983dda Merge pull request #16 from visibilityspots/master
Removed GOARCH parameter to be able to build arm images
2021-02-01 19:47:14 +01:00
Jan Collijs 99f5833aee Removed GOARCH parameter to be able to build arm images 2021-02-01 19:19:24 +01:00
Ben Cox 13d3a39e11 Allow easier LISTEN_ADDRESS overrides to dockerfile
Because listen.address was hard coded in the Docker file, it would override any ENVVAR set in any
other docker file, this made it hard to change the default listening port to anything else. By switching
to `ENV LISTEN_ADDRESS=0.0.0.0:9094` people should be able to override the address that
the docker file listens on much easier.

This closes #12 
This closes #11
2021-01-18 12:19:15 +00:00
Ben Cartwright-Cox ff1f9273cb Relax the DISCORD_WEBHOOK requirements, it can be any URL.
and if it does not match the discord regex it prints a warning
but lets things go ahead, this is handy for debugging and
canary subdomains of discord.

H/T to Tom Bowditch who discovered the latter part
2020-12-02 12:13:13 +00:00
Ben Cox 462a419eed Merge pull request #9 from FinweVI/master
Add listen.address as a CLI parameter and check WebHook
2020-11-29 21:04:10 +00:00
Julien Vallée c650e0c78a Accept Listen Address as an ENV VAR as well 2020-10-16 17:58:23 +02:00
Julien Vallée eac7521a45 Have the daemon listening on 0.0.0.0
As we default to 127.0.0.1 in the code
2020-10-16 14:30:59 +02:00
Julien Vallée 03a58c8afd Add listen.address as a CLI parameter and check WebHook 2020-10-16 14:12:58 +02:00
Ben Cartwright-Cox 2e7369c932 Shuffle file names around for prevent CDN caching :/ 2020-07-19 19:57:44 +01:00
Ben Cartwright-Cox 3fdeed85bb update the demo picture for the readme 2020-07-19 19:55:40 +01:00
Ben Cox e7453d0523 Merge pull request #7 from prmsrswt/discord-rich-embed
Send alerts in Discord's rich embed format
2020-07-19 20:54:27 +02:00
Prem Kumar db70e42850 Add summary in content 2020-07-20 00:04:19 +05:30
Prem Kumar 093b67f417 Send alerts in Discord's rich embed format 2020-07-19 22:55:22 +05:30
Ben Cox 3b4fad8a09 Add docker hub link
I generally run these bins as systemd services, but you want to go all galaxy brain yourselves, here is the convenient option
2020-05-17 12:21:16 +01:00
Ben Cartwright-Cox 45ea6ee661 Allow compatibility with the flag for the webhook 2018-10-15 22:01:08 +01:00
Ben Cartwright-Cox 85ff16f0f3 Merge branch 'rewbycraft-master' 2018-10-14 12:44:31 +01:00
Roelf Wichertjes f581cbcce5 Fix misrepresentation of alerts. 2018-10-14 12:37:23 +02:00
Ben Cox e6edc2294a Merge pull request #3 from funkypenguin/master
Fix error checking, add Dockerfile
2018-09-19 13:29:25 +01:00
David Young 34535fcb78 Add newline between alerts 2018-09-20 00:09:29 +12:00
David Young ba34a16bd0 Added little diamond emoji 2018-09-18 22:29:20 +12:00
David Young 0b75833a50 Updated Dockerization 2018-09-18 12:47:17 +12:00
David Young a95f814b81 Added Dockerfile 2018-09-18 12:11:31 +12:00
Ben Cox 0b06423b70 Merge pull request #2 from intrand/master
add flexibility for swarm friendliness
2018-08-19 21:52:18 +01:00
intrand 809cc41335 listen on all available ports swarm +compatibility 2018-08-18 23:59:55 -04:00
intrand 020c84419f configurable webhook url via env var
ugly, but functional; no safety included.
2018-08-18 23:59:11 -04:00
Ben Cox 2116bc8460 honk
If there is no common summary, fix the Discord formatting
2018-04-15 15:55:25 -04:00
Tom Bowditch 7704e73594 If there is no common summary, fix the Discord formatting 2018-04-15 20:54:42 +01:00
Ben Cartwright-Cox 5da0dc9494 Nicer formatting 2018-03-29 00:37:26 +01:00
Ben Cartwright-Cox a33608f222 Finishing tweaks to make it decent 2018-03-28 18:08:17 +01:00
Ben Cartwright-Cox 19878e15d1 Init checkin 2018-03-28 17:36:17 +01:00
Ben Cartwright-Cox 8685381e20 init 2018-03-28 17:36:04 +01:00
69 changed files with 2945 additions and 214 deletions
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -eo pipefail
if [[ -z "${VERSION}" ]]; then
echo "VERSION environment variable should be set"
exit 1
fi
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG:-"speckle/alertmanager-discord"}"
export DOCKER_BUILDKIT=1
docker build --tag "${DOCKER_IMAGE_TAG}:${VERSION}" --build-arg="APPLICATION_VERSION=${VERSION}" --file ./Dockerfile .
+158 -2
View File
@@ -1,5 +1,161 @@
version: 2.1
workflows: {}
workflows:
build-image:
jobs:
- get-version:
filters:
tags: &filter-allow-all
only: /.*/
jobs: {}
- pre-commit:
filters:
tags: *filter-allow-all
- docker-build:
filters:
tags: &filter-ignore-all
ignore: /.*/
branches:
ignore:
- main
requires:
- get-version
- docker-build-and-publish:
context:
- docker-hub
filters:
tags: *filter-allow-all
branches:
only:
- main
requires:
- get-version
- pre-commit
- helm-package-and-publish:
filters:
tags: *filter-allow-all
branches:
only:
- main
requires:
- docker-build
- docker-build-and-publish
jobs:
get-version:
docker: &docker-image
- image: cimg/base:2024.01
working_directory: &workingdir /tmp/ci
steps:
- checkout
- run: mkdir -p workspace
- run:
name: set version
command: |
echo "export VERSION=$(.circleci/get_version.sh)" >> workspace/env-vars
- run:
name: store version
command: |
cat workspace/env-vars >> $BASH_ENV
- run:
name: echo version
command: |
echo "VERSION=${VERSION}"
- persist_to_workspace:
root: workspace
paths:
- env-vars
pre-commit:
parameters:
config_file:
default: ./.pre-commit-config.yaml
description: Optional, path to pre-commit config file.
type: string
cache_prefix:
default: ""
description: |
Optional cache prefix to be used on CircleCI. Can be used for cache busting or to ensure multiple jobs use different caches.
type: string
docker:
- image: speckle/pre-commit-runner:latest
resource_class: &docker-resource-class medium
working_directory: *workingdir
steps:
- checkout
- restore_cache:
keys:
- cache-pre-commit-<<parameters.cache_prefix>>-{{ checksum "<<parameters.config_file>>" }}
- run:
name: Install pre-commit hooks
command: pre-commit install-hooks --config <<parameters.config_file>>
- save_cache:
key: cache-pre-commit-<<parameters.cache_prefix>>-{{ checksum "<<parameters.config_file>>" }}
paths:
- ~/.cache/pre-commit
- run:
name: Run pre-commit
command: pre-commit run --all-files --config <<parameters.config_file>>
- run:
command: git --no-pager diff
name: git diff
when: on_fail
docker-build-and-publish:
docker: *docker-image
resource_class: *docker-resource-class
working_directory: *workingdir
steps:
- checkout
- attach_workspace:
at: /tmp/ci/workspace
- run:
name: populate environment variables
command: |
cat workspace/env-vars >> $BASH_ENV
- setup_remote_docker: &remote-docker
version: default
docker_layer_caching: true
- run:
name: Build and Publish
command: ./.circleci/build.sh && ./.circleci/publish.sh
docker-build:
docker: *docker-image
resource_class: *docker-resource-class
working_directory: *workingdir
steps:
- checkout
- attach_workspace:
at: /tmp/ci/workspace
- run:
name: populate environment variables
command: |
cat workspace/env-vars >> $BASH_ENV
- setup_remote_docker: *remote-docker
- run:
name: Build
command: ./.circleci/build.sh
helm-package-and-publish:
docker:
- image: quay.io/helmpack/chart-testing:v3.10.1-amd64
resource_class: *docker-resource-class
working_directory: *workingdir
steps:
- checkout
- add_ssh_keys:
fingerprints:
- "30:cb:bf:0c:ec:3b:fe:88:6c:be:af:b3:d1:36:75:db"
- attach_workspace:
at: /tmp/workspace
- run:
name: populate environment variables
command: |
cat /tmp/workspace/env-vars >> $BASH_ENV
- run:
name: Build and Publish
command: ./.circleci/package_and_publish_helm.sh
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
set -eo pipefail
if [[ "${CIRCLE_TAG}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "${CIRCLE_TAG}"
exit 0
fi
# shellcheck disable=SC2068,SC2046
LAST_RELEASE="$(git describe --always --tags $(git rev-list --tags) | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)"
NEXT_RELEASE="$(echo "${LAST_RELEASE}" | awk -F. -v OFS=. '{$NF += 1 ; print}')"
if [[ "${CIRCLE_BRANCH}" == "main" ]]; then
echo "${NEXT_RELEASE}-alpha.${CIRCLE_BUILD_NUM}"
exit 0
fi
# docker has a 128 character tag limit, so ensuring the branch name will be short enough
# helm uses semver 2, only valid characters are a-zA-Z0-9 and hyphen '-'
# shellcheck disable=SC2034
BRANCH_NAME_TRUNCATED="$(echo "${CIRCLE_BRANCH}" | cut -c -50 | sed 's/[^a-zA-Z0-9.-]/-/g')"
echo "${NEXT_RELEASE}-branch.${BRANCH_NAME_TRUNCATED}.${CIRCLE_BUILD_NUM}"
exit 0
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -eo pipefail
TEMP_PACKAGE_DIR="${TEMP_PACKAGE_DIR:-"/tmp/.cr-release-packages"}"
HELM_PACKAGE_BRANCH="${HELM_PACKAGE_BRANCH:-"gh-pages"}"
HELM_STABLE_BRANCH="${HELM_STABLE_BRANCH:-"main"}"
HELM_CHART_DIR_PATH="${HELM_CHART_DIR_PATH:-"deploy/helm"}"
if [[ -z "${VERSION}" ]]; then
echo "VERSION environment variable should be set"
exit 1
fi
if [[ -z "${GIT_EMAIL}" ]]; then
echo "GIT_EMAIL environment variable should be set"
exit 1
fi
if [[ -z "${GIT_USERNAME}" ]]; then
echo "GIT_USERNAME environment variable should be set"
exit 1
fi
echo "🧹 cleaning temporary directory"
rm -rf "${TEMP_PACKAGE_DIR}" || true
mkdir "${TEMP_PACKAGE_DIR}"
helm version -c
echo "🏗️ building dependencies"
helm dependency build "${HELM_CHART_DIR_PATH}"
echo "🎁 packaging ${HELM_CHART_DIR_PATH} with version: ${VERSION}"
helm package "${HELM_CHART_DIR_PATH}" --dependency-update --version "${VERSION}" --app-version "${VERSION}" --destination "${TEMP_PACKAGE_DIR}"
echo "⏬ checking out git branch '${HELM_PACKAGE_BRANCH}'"
git config user.email "${GIT_EMAIL}"
git config user.name "${GIT_USERNAME}"
git fetch
git switch "${HELM_PACKAGE_BRANCH}"
if [[ -n "${CIRCLE_TAG}" || "${CIRCLE_BRANCH}" == "${HELM_STABLE_BRANCH}" ]]; then
echo "🛻 copying packages to stable directory"
cp -a "${TEMP_PACKAGE_DIR}/." stable/
pushd stable
helm repo index .
popd
else
cp -a "${TEMP_PACKAGE_DIR}/." incubator/
echo "🛻 copying packages to incubator directory"
pushd incubator
helm repo index .
popd
fi
echo "⏫ adding, commiting, and pushing to git repository"
git add .
git commit -m "updating helm chart to version ${VERSION}"
git push --set-upstream origin "${HELM_PACKAGE_BRANCH}"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -eo pipefail
if [[ -z "${VERSION}" ]]; then
echo "VERSION environment variable should be set"
exit 1
fi
if [[ -z "${DOCKER_REG_PASS}" ]]; then
echo "DOCKER_REG_PASS environment variable should be set"
exit 1
fi
if [[ -z "${DOCKER_REG_USER}" ]]; then
echo "DOCKER_REG_USER environment variable should be set"
exit 1
fi
DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG:-"speckle/alertmanager-discord"}"
docker tag "${DOCKER_IMAGE_TAG}:${VERSION}" "${DOCKER_IMAGE_TAG}:latest"
echo "${DOCKER_REG_PASS}" | docker login -u "${DOCKER_REG_USER}" --password-stdin "${DOCKER_REG_URL}"
docker push -a "${DOCKER_IMAGE_TAG}"
Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

+10
View File
@@ -0,0 +1,10 @@
alertmanager-discord
alertmanager-discord.darwin
alertmanager-discord.linux
cover.out
# CircleCI orb Helm package & push
.cr-release-packages
# pre-commit
node_modules
+52 -2
View File
@@ -1,6 +1,56 @@
repos:
- repo: https://github.com/pre-commit/mirrors-prettier
rev: "v3.1.0"
hooks:
- id: prettier
exclude: "deploy/helm"
- repo: https://github.com/hadolint/hadolint
rev: "v2.12.1-beta"
hooks:
- id: hadolint
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: "v4.5.0"
hooks:
- id: check-yaml
exclude: "deploy/helm"
- id: check-merge-conflict
- id: check-executables-have-shebangs
- id: check-shebang-scripts-are-executable
- id: check-symlinks
- id: check-vcs-permalinks
- id: trailing-whitespace
- id: end-of-file-fixer
exclude: "deploy/helm/README.md"
- repo: https://github.com/syntaqx/git-hooks
rev: "v0.0.17"
rev: "v0.0.18"
hooks:
- id: circleci-config-validate
- repo: https://github.com/Jarmos-san/shellcheck-precommit
rev: "v0.2.0"
hooks:
- id: shellcheck-system
- repo: https://github.com/dnephin/pre-commit-golang
rev: "v0.5.1"
hooks:
- id: go-fmt
# - id: go-vet
- id: no-go-testing
- id: go-critic
- id: go-unit-tests
- id: go-build
- id: go-mod-tidy
- repo: https://github.com/norwoodj/helm-docs
rev: v1.12.0
hooks:
- id: helm-docs
args:
- --chart-search-root=deploy/helm
ci:
autoupdate_schedule: quarterly
+30
View File
@@ -0,0 +1,30 @@
# Built following https://medium.com/@chemidy/create-the-smallest-and-secured-golang-docker-image-based-on-scratch-4752223b7324
# STEP 1 build executable binary
FROM golang:alpine as builder
# Install SSL ca certificates
RUN apk update && apk add --no-cache \
git=2.45.2-r0 \
ca-certificates=20240226-r0
# Create appuser
RUN adduser -D -g '' appuser
COPY . $GOPATH/src/alertmanager-discord/
WORKDIR $GOPATH/src/alertmanager-discord/
ARG APPLICATION_VERSION
ENV APPLICATION_VERSION="${APPLICATION_VERSION:-"0.0.0"}"
#get dependancies
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s -X 'github.com/specklesystems/alertmanager-discord/pkg/version.Version=${APPLICATION_VERSION}'" -o /go/bin/alertmanager-discord main.go
# STEP 2 build a small image
# start from scratch
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /etc/passwd /etc/passwd
# Copy our static executable
COPY --from=builder /go/bin/alertmanager-discord /bin/alertmanager-discord
ENV LISTEN_ADDRESS=0.0.0.0:9094
EXPOSE 9094
USER appuser
ENTRYPOINT ["/bin/alertmanager-discord"]
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+173
View File
@@ -0,0 +1,173 @@
# alertmanager-discord
This is a webserver that accepts webhooks from AlertManager. It will post your Prometheus alert notifications into a Discord channel as they trigger:
![](/.github/discord-screenshot.png)
## Warning
This program is not a replacement to alertmanager, it accepts webhooks from alertmanager, not Prometheus.
The standard "dataflow" should be:
```text
Prometheus -------------> alertmanager -------------------> alertmanager-discord
alerting: receivers:
alertmanagers: - name: 'discord_webhook' environment:
- static_configs: webhook_configs: - DISCORD_WEBHOOK=https://discordapp.com/api/we...
- targets: - url: 'http://localhost:9094'
- 127.0.0.1:9093
```
## Features
- REST API
- Small, standalone binary ( less than 12 Mb)
- Small Docker (OCI) Image (also less than 12 Mb) with minimal dependencies
- Helm Chart for deployment to Kubernetes.
- includes Cilium Network Policies which can be optionally enabled.
- Liveness and Readiness probes, at `/liveness` and `/readiness`.
- Unit and Integration tests, approx 90% coverage.
- Structured Logging.
- Prometheus metrics at `/metrics`.
### Roadmap
- Template Discord messages
- REST API documented with OpenAPI (Swagger) specification.
## Example alertmanager config
```yaml
global:
# The smarthost and SMTP sender used for mail notifications.
smtp_smarthost: "localhost:25"
smtp_from: "alertmanager@example.org"
smtp_auth_username: "alertmanager"
smtp_auth_password: "password"
# The directory from which notification templates are read.
templates:
- "/etc/alertmanager/template/*.tmpl"
# The root route on which each incoming alert enters.
route:
group_by: ["alertname"]
group_wait: 20s
group_interval: 5m
repeat_interval: 3h
receiver: discord_webhook
receivers:
- name: "discord_webhook"
webhook_configs:
- url: "http://localhost:9094"
```
## Deployment
### Running binary
```shell
go run . --discord_webhook_url=https://discord.com/api/webhooks/123456789123456789/abc
```
You may instead provide the Discord webhook url by environment variable, `DISCORD_WEBHOOK_URL`, or via a configuration file:
```yaml
discord_webhook_url: https://discord.com/api/webhooks/123456789123456789/abc
```
```shell
go run . --configuration_file_path=/path/to/your/config.yaml
```
### Docker or OCI-compatible container runtime
If you wish to deploy this to Docker, or similar OCI-compatible container runtime, you can pull the OCI image from the [Docker Hub repository](https://hub.docker.com/r/speckle/alertmanager-discord/).
### Kubernetes Helm Chart
If you wish to deploy this to Kubernetes, this repository contains a Helm Chart.
Firstly, please deploy a Secret with your configuration information, the discord webhook url is required.:
```yaml
apiVersion: v1
kind: Secret
metadata:
name: discord-config
data:
"config.yaml": |
discord_webhook_url: https://discord.com/api/webhooks/123456789123456789/abc
```
```shell
helm upgrade --install \
--create-namespace \
--namespace alertmanager-discord
alertmanager-discord \
./deploy/helm
```
You can optionally also provide a values yaml file, `--values ./your-values.yaml`, to override the default values.
## Development
To build the binary locally:
```shell
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o /tmp/alertmanager-discord ./cmd/alertforward
```
To build the Dockerfile locally:
```shell
docker build . -t speckle/alertmanager-discord:local
```
Or to build the Dockerfile on Apple Silicon (M1, M2 etc.):
```shell
docker buildx build --platform=linux/amd64 . -t speckle/alertmanager-discord:local
```
### Pre-commit
A pre-commit configuration is provided. With [pre-commit](https://pre-commit.com/) installed, run:
```shell
pre-commit install
```
This should install hooks on git, which will cause pre-commit to run every time a git commit is created.
Alternatively, to run pre-commit on the entire repository:
```shell
pre-commit run --all-files
```
### Upgrading
```shell
go get -u ./...
```
### Testing
```shell
go test ./... -v -cover -test.shuffle on
```
## Design philosophy
- small footprint
- Minimal external dependencies
- binary should be agnostic to deployment location or method.
- synchronous; the connection to the server is kept open until the connection to Discord has responded (or errored). This allows the response code or error to be returned to the request - we can have more confidence that the message was sent, and have a better ability to quickly correlate which requests caused an error.
## Acknowledgements
This repository is forked from [benjojo/alertmanager-discord](https://github.com/benjojo/alertmanager-discord) under the Apache 2.0 license
+125
View File
@@ -0,0 +1,125 @@
package cmd
import (
"os"
"strings"
"time"
"github.com/specklesystems/alertmanager-discord/pkg/flags"
"github.com/specklesystems/alertmanager-discord/pkg/server"
"github.com/specklesystems/alertmanager-discord/pkg/version"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
const (
defaultConfigurationPath = "/etc/alertmanager-discord/config.yaml"
defaultMaxBackoffTimeSeconds = 10
defaultLogLevel = "info"
)
var (
configurationFilePath string
webhookURL string
listenAddress string
logLevel string
maximumBackoffTimeSeconds int
)
func init() {
defineConfigurationVariable(&configurationFilePath, rootCmd.Flags().StringVarP, flags.ConfigurationPathFlagKey, "c", defaultConfigurationPath, "Path to the configuration file.")
defineConfigurationVariable(&webhookURL, rootCmd.Flags().StringVarP, flags.DiscordWebhookUrlFlagKey, "d", "", "Url to the Discord webhook API endpoint.")
defineConfigurationVariable(&listenAddress, rootCmd.Flags().StringVarP, flags.ListenAddressFlagKey, "l", server.DefaultListenAddress, "The address (host:port) which the server will attempt to bind to and listen on.")
defineConfigurationVariable(&logLevel, rootCmd.Flags().StringVarP, flags.LogLevelFlagKey, "", defaultLogLevel, "The minimum level of logging to be produced by the pod. Acceptable values, in ascending order, are 'trace', 'debug', 'info', 'warn', 'error', 'fatal', 'panic', or 'disabled'.")
defineConfigurationVariable(&maximumBackoffTimeSeconds, rootCmd.Flags().IntVarP, flags.MaxBackoffTimeSecondsFlagKey, "", defaultMaxBackoffTimeSeconds, "The maximum elapsed duration (expressed as an integer number of seconds) to allow the Discord client to continue retrying to send messages to the Discord API.")
}
func defineConfigurationVariable[K int | string](variable *K, flagParser func(*K, string, string, K, string), flagKey string, shorthand string, defaultValue K, description string) {
viper.SetDefault(flagKey, defaultValue)
viper.BindEnv(flagKey, strings.ToUpper(flagKey))
flagParser(variable, flagKey, shorthand, defaultValue, description)
viper.BindPFlag(flagKey, rootCmd.Flags().Lookup(flagKey))
}
var rootCmd = &cobra.Command{
Use: "alertmanager-discord",
Version: version.Version,
Short: "Forwards AlertManager alerts to Discord.",
Long: `A simple web server that accepts AlertManager webhooks,
translates the data to match Discord's message specifications,
and forwards that to Discord's message API endpoint.`,
Run: func(cmd *cobra.Command, args []string) {
zerolog.TimeFieldFormat = time.RFC3339
zerolog.SetGlobalLevel(zerolog.InfoLevel)
// these log messages are generated before the log level is set
log.Debug().Msgf("Attempting to read from configuration file path: ('%s')", configurationFilePath)
viper.SetConfigFile(configurationFilePath)
if err := viper.ReadInConfig(); err != nil {
log.Info().Err(err).Msgf("Unable to read configuration file at path ('%s'). Attempting to parse command line arguments or environment variables, the command line argument has higher order of precedence.", configurationFilePath)
}
if viper.GetString(flags.DiscordWebhookUrlFlagKey) != "" {
webhookURL = viper.GetString(flags.DiscordWebhookUrlFlagKey)
}
if viper.GetString(flags.ListenAddressFlagKey) != "" {
listenAddress = viper.GetString(flags.ListenAddressFlagKey)
}
setGlobalLogLevel(viper.GetString(flags.LogLevelFlagKey))
if viper.GetString(flags.MaxBackoffTimeSecondsFlagKey) != "" {
maximumBackoffTimeSeconds = viper.GetInt(flags.MaxBackoffTimeSecondsFlagKey)
}
amds := server.AlertManagerDiscordServer{
MaximumBackoffTimeSeconds: time.Duration(maximumBackoffTimeSeconds) * time.Second,
}
stopCh, err := amds.ListenAndServe(webhookURL, listenAddress)
defer func() {
if err = amds.Shutdown(); err != nil {
log.Fatal().Err(err).Msg("Error while shutting down server.")
}
}()
if err != nil {
log.Error().Err(err).Msg("Error in AlertManager-Discord server")
close(stopCh)
}
// Waits here for SIGINT (kill -2) or for channel to be closed (which can occur if there is an error in the server)
<-stopCh
},
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
log.Error().Err(err).Msg("Error when executing command. Exiting program...")
os.Exit(1)
}
}
func setGlobalLogLevel(logLevel string) {
switch logLevel {
case "trace":
zerolog.SetGlobalLevel(zerolog.TraceLevel)
case "debug":
zerolog.SetGlobalLevel(zerolog.DebugLevel)
case "info":
zerolog.SetGlobalLevel(zerolog.InfoLevel)
case "warn":
zerolog.SetGlobalLevel(zerolog.WarnLevel)
case "error":
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
case "fatal":
zerolog.SetGlobalLevel(zerolog.FatalLevel)
case "panic":
zerolog.SetGlobalLevel(zerolog.PanicLevel)
case "disabled":
zerolog.SetGlobalLevel(zerolog.Disabled)
default:
break
}
}
+23
View File
@@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v2
name: alertmanager-discord
description: A Helm chart to deploy alertmanager-discord to Kubernetes
type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.0.0-local
# This is the version number of the application being deployed.
appVersion: "0.0.0-local"
+41
View File
@@ -0,0 +1,41 @@
# alertmanager-discord
![Version: 0.0.0-local](https://img.shields.io/badge/Version-0.0.0--local-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.0.0-local](https://img.shields.io/badge/AppVersion-0.0.0--local-informational?style=flat-square)
A Helm chart to deploy alertmanager-discord to Kubernetes
## Values
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| affinity | object | `{}` | |
| ciliumNetworkPolicy.alertManagerSelectorLabels | object | `{}` | the labels applied to the alertmanager which will send data to this service. If Cilium Network Policy is enabled, ingress to this service is only allowed from a pod matching these labels. |
| ciliumNetworkPolicy.enabled | bool | `false` | |
| fullnameOverride | string | `""` | |
| image.pullPolicy | string | `"Always"` | |
| image.repository | string | `"speckle/alertmanager-discord"` | |
| image.tag | string | `"latest"` | Overrides the image tag whose default is the chart appVersion. |
| imagePullSecrets | list | `[]` | |
| nameOverride | string | `""` | |
| nodeSelector | object | `{}` | |
| podAnnotations | object | `{}` | |
| podSecurityContext.fsGroup | int | `2000` | |
| priorityClassName | string | `""` | https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/ |
| replicaCount | int | `1` | |
| resources.limits.cpu | string | `"100m"` | |
| resources.limits.memory | string | `"128Mi"` | |
| resources.requests.cpu | string | `"50m"` | |
| resources.requests.memory | string | `"64Mi"` | |
| securityContext.capabilities.drop[0] | string | `"ALL"` | |
| securityContext.readOnlyRootFilesystem | bool | `true` | |
| securityContext.runAsNonRoot | bool | `true` | |
| securityContext.runAsUser | int | `1000` | |
| server.configuration.key | string | `"config.yaml"` | the key within the Kubernetes Secret. This key is expected to be a filename, as it will for the path for the configuration file when mounted to the container. |
| server.configuration.name | string | `"discord-config"` | name of the Kubernetes Secret containing the configuration file, will be mounted to the container. Must be in the same namespace as this helm chart is deployed. |
| service.port | int | `9094` | The port to which alertmanager should push alerts |
| service.type | string | `"ClusterIP"` | |
| serviceAccount.annotations | object | `{}` | Annotations to add to the service account |
| serviceAccount.create | bool | `true` | Specifies whether a service account should be created |
| serviceAccount.name | string | `""` | The name of the service account to use. If not set and create is true, a name is generated using the fullname template |
| tolerations | list | `[]` | |
+16
View File
@@ -0,0 +1,16 @@
1. Get the application URL by running these commands:
{{- if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "alertmanager-discord.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "alertmanager-discord.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "alertmanager-discord.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "alertmanager-discord.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}
+62
View File
@@ -0,0 +1,62 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "alertmanager-discord.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "alertmanager-discord.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "alertmanager-discord.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "alertmanager-discord.labels" -}}
helm.sh/chart: {{ include "alertmanager-discord.chart" . }}
{{ include "alertmanager-discord.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "alertmanager-discord.selectorLabels" -}}
app.kubernetes.io/name: {{ include "alertmanager-discord.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "alertmanager-discord.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "alertmanager-discord.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
@@ -0,0 +1,24 @@
{{- if .Values.ciliumNetworkPolicy.enabled -}}
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: alertmanager-discord
spec:
endpointSelector:
matchLabels:
{{- include "alertmanager-discord.selectorLabels" . | nindent 6 }}
ingress:
- fromEndpoints:
- matchLabels:
{{- .Values.ciliumNetworkPolicy.alertManagerSelectorLabels | toYaml | nindent 12 }}
toPorts:
- ports:
- port: "{{ .Values.service.port }}"
egress:
- toFQDNs:
- matchPattern: "discord.com"
- matchPattern: "discordapp.com"
toPorts:
- ports:
- port: "443"
{{- end -}}
+74
View File
@@ -0,0 +1,74 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "alertmanager-discord.fullname" . }}
labels:
{{- include "alertmanager-discord.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "alertmanager-discord.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "alertmanager-discord.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if .Values.priorityClassName }}
priorityClassName: {{ .Values.priorityClassName }}
{{- end }}
serviceAccountName: {{ include "alertmanager-discord.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 9094
protocol: TCP
livenessProbe:
httpGet:
path: /liveness
port: http
readinessProbe:
httpGet:
path: /readiness
port: http
resources:
{{- toYaml .Values.resources | nindent 12 }}
env:
- name: CONFIGURATION_FILE_PATH
value: {{ printf "/etc/alertmanager-discord/%s" .Values.server.configuration.key }}
volumeMounts:
- name: alertmanager-discord-config
mountPath: /etc/alertmanager-discord
readOnly: true
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
volumes:
- name: alertmanager-discord-config
secret:
secretName: {{ .Values.server.configuration.name }}
optional: false
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "alertmanager-discord.fullname" . }}
labels:
{{- include "alertmanager-discord.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "alertmanager-discord.selectorLabels" . | nindent 4 }}
+12
View File
@@ -0,0 +1,12 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "alertmanager-discord.serviceAccountName" . }}
labels:
{{- include "alertmanager-discord.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
+12
View File
@@ -0,0 +1,12 @@
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: {{ include "alertmanager-discord.fullname" . }}
labels:
{{- include "alertmanager-discord.labels" . | nindent 4 }}
spec:
selector:
matchLabels:
{{- include "alertmanager-discord.selectorLabels" . | nindent 6 }}
endpoints:
- port: http
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "alertmanager-discord.fullname" . }}-test-connection"
labels:
{{- include "alertmanager-discord.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "alertmanager-discord.fullname" . }}:{{ .Values.service.port }}/readiness']
restartPolicy: Never
+218
View File
@@ -0,0 +1,218 @@
{
"title": "Chart Values",
"type": "object",
"properties": {
"replicaCount": {
"type": "number",
"description": "The number of replicas of the alertmanager-discord pod to deploy",
"default": 1
},
"image": {
"type": "object",
"properties": {
"repository": {
"type": "string",
"description": "The OCI compatible repository containing the image to deploy",
"default": "speckle/alertmanager-discord"
},
"pullPolicy": {
"type": "string",
"description": "Controls when the image should be pulled from the repository.",
"default": "Always"
},
"tag": {
"type": "string",
"description": "Overrides the image tag whose default is the chart appVersion.",
"default": "latest"
}
}
},
"imagePullSecrets": {
"type": "array",
"description": "If the image is in a private repository, pull secrets are required.",
"default": [],
"items": {}
},
"nameOverride": {
"type": "string",
"description": "Override the name of this Helm Release",
"default": ""
},
"fullnameOverride": {
"type": "string",
"description": "Override the full name generated for this Helm Release",
"default": ""
},
"serviceAccount": {
"type": "object",
"properties": {
"create": {
"type": "boolean",
"description": "Specifies whether a service account should be created",
"default": true
},
"annotations": {
"type": "object",
"description": "Annotations to add to the service account",
"default": {}
},
"name": {
"type": "string",
"description": "The name of the service account to use. If not set and create is true, a name is generated using the fullname template",
"default": ""
}
}
},
"podAnnotations": {
"type": "object",
"description": "Annotations to apply to pods generated by this Helm Chart",
"default": {}
},
"podSecurityContext": {
"type": "object",
"properties": {
"fsGroup": {
"type": "number",
"description": "The group number in which the pod user will operate",
"default": 2000
}
}
},
"securityContext": {
"type": "object",
"properties": {
"capabilities": {
"type": "object",
"properties": {
"drop": {
"type": "array",
"description": "The kernel capabilities to remove from this pod.",
"default": [
"ALL"
],
"items": {
"type": "string"
}
}
}
},
"readOnlyRootFilesystem": {
"type": "boolean",
"description": "Deteremines whether the root file system is readonly.",
"default": true
},
"runAsNonRoot": {
"type": "boolean",
"description": "Deteremines whether the user is non-root.",
"default": true
},
"runAsUser": {
"type": "number",
"description": "The uid of the user under which the pod is run.",
"default": 1000
}
}
},
"service": {
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "The type of service (e.g. ClusterIP or Loadbalancer)",
"default": "ClusterIP"
},
"port": {
"type": "number",
"description": "The port to which alertmanager should push alerts",
"default": 9094
}
}
},
"resources": {
"type": "object",
"properties": {
"limits": {
"type": "object",
"properties": {
"cpu": {
"type": "string",
"description": "The maximum CPU that will be made available to the pod",
"default": "100m"
},
"memory": {
"type": "string",
"description": "The maximum memory that will be made available to the pod. Above this value the pod runs the risk of being OOMKilled",
"default": "128Mi"
}
}
},
"requests": {
"type": "object",
"properties": {
"cpu": {
"type": "string",
"description": "The CPU that is requested by this pod when deployed, used for space allocation on nodes.",
"default": "50m"
},
"memory": {
"type": "string",
"description": "The memory that is requested by this pod when deployed, used for space allocation on nodes.",
"default": "64Mi"
}
}
}
}
},
"nodeSelector": {
"type": "object",
"description": "Assign the pods deployed by this Helm Chart to a specific node which matches the selector.",
"default": {}
},
"tolerations": {
"type": "array",
"description": "Allows the pods deployed by this Helm Chart to tolerate specific criteria which may be found applied to nodes.",
"default": [],
"items": {}
},
"affinity": {
"type": "object",
"description": "The pods deployed by this Helm Chart will tend to this specific criteria which may be found applied to nodes.",
"default": {}
},
"server": {
"type": "object",
"properties": {
"configuration": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The name of the Kubernetes Secret containing the Discord webhook url. Must be in the same namespace as this helm chart is deployed.",
"default": "discord-webhook"
},
"key": {
"type": "string",
"description": "The key within the Opaque Kubernetes Secret containing the webhook value.",
"default": "discord-uri"
}
}
}
}
},
"ciliumNetworkPolicy": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"description": "If enabled, deploys a Cilium Network Policy to restrict network ingress and egress to the pods deployed by this Helm Chart",
"default": false
},
"alertManagerSelectorLabels": {
"type": "object",
"description": "The labels expected to be applied to the AlertManager pods which will send data to this service. If Cilium Network Policy is enabled, ingress to this service is only allowed from a pod matching these labels.",
"default": {}
}
}
}
}
}
+68
View File
@@ -0,0 +1,68 @@
replicaCount: 1
image:
repository: speckle/alertmanager-discord
pullPolicy: Always
# -- Overrides the image tag whose default is the chart appVersion.
tag: "latest"
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
serviceAccount:
# -- Specifies whether a service account should be created
create: true
# -- Annotations to add to the service account
annotations: {}
# -- The name of the service account to use.
# If not set and create is true, a name is generated using the fullname template
name: ""
podAnnotations: {}
podSecurityContext:
fsGroup: 2000
# -- https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/
priorityClassName: ""
securityContext:
capabilities:
drop:
- ALL
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
service:
type: ClusterIP
# -- The port to which alertmanager should push alerts
port: 9094
resources:
limits:
cpu: 100m
memory: 128Mi
requests:
cpu: 50m
memory: 64Mi
nodeSelector: {}
tolerations: []
affinity: {}
server:
configuration:
# -- name of the Kubernetes Secret containing the configuration file, will be mounted to the container. Must be in the same namespace as this helm chart is deployed.
name: discord-config
# -- the key within the Kubernetes Secret. This key is expected to be a filename, as it will for the path for the configuration file when mounted to the container.
key: config.yaml
# within the config.yaml data, it should be yaml formatted with the key `discord_webhook_url`, and optionally keys `listen_address` & `max_backoff_time_seconds`. An example of the data expected can be found at ./test/test-config.yaml
ciliumNetworkPolicy:
enabled: false
# -- the labels applied to the alertmanager which will send data to this service. If Cilium Network Policy is enabled, ingress to this service is only allowed from a pod matching these labels.
alertManagerSelectorLabels: {}
+47
View File
@@ -0,0 +1,47 @@
module github.com/specklesystems/alertmanager-discord
go 1.21
toolchain go1.22.1
require (
github.com/cenkalti/backoff/v4 v4.3.0
github.com/google/uuid v1.6.0
github.com/prometheus/client_golang v1.19.1
github.com/rs/zerolog v1.33.0
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.9.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.54.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/sagikazarmark/locafero v0.6.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
golang.org/x/sys v0.21.0 // indirect
golang.org/x/text v0.16.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+107
View File
@@ -0,0 +1,107 @@
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/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
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-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
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/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
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.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.54.0 h1:ZlZy0BgJhTwVZUn7dLOkwCZHUkrAqd3WYtcFCWnM1D8=
github.com/prometheus/common v0.54.0/go.mod h1:/TQgMJP5CuVYveyT7n/0Ix8yLNNXy9yRSkhnLTHPDIQ=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3N51bwOk=
github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+9
View File
@@ -0,0 +1,9 @@
package main
import (
"github.com/specklesystems/alertmanager-discord/cmd"
)
func main() {
cmd.Execute()
}
+221
View File
@@ -0,0 +1,221 @@
package alertforwarder
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/specklesystems/alertmanager-discord/pkg/alertmanager"
"github.com/specklesystems/alertmanager-discord/pkg/discord"
"github.com/specklesystems/alertmanager-discord/pkg/logging"
"github.com/specklesystems/alertmanager-discord/pkg/prometheus"
"github.com/google/uuid"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
const (
maxLogLength = 1024
)
type AlertForwarderHandler struct {
af AlertForwarder
}
func NewAlertForwarderHandler(client *http.Client, webhookURL string, maximumBackoffElapsedTime time.Duration) *AlertForwarderHandler {
return &AlertForwarderHandler{
af: NewAlertForwarder(client, webhookURL, maximumBackoffElapsedTime),
}
}
func (h *AlertForwarderHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.af.TransformAndForward(w, r)
}
type AlertForwarder struct {
client *discord.Client
}
func NewAlertForwarder(client *http.Client, webhookURL string, maximumBackoffElapsedTime time.Duration) AlertForwarder {
return AlertForwarder{
client: discord.NewClient(client, webhookURL, maximumBackoffElapsedTime),
}
}
func (af *AlertForwarder) groupAlerts(amo *alertmanager.Out) map[string][]alertmanager.Alert {
groupedAlerts := make(map[string][]alertmanager.Alert)
for _, alert := range amo.Alerts {
groupedAlerts[alert.Status] = append(groupedAlerts[alert.Status], alert)
}
return groupedAlerts
}
func (af *AlertForwarder) sendWebhook(correlationId string, amo *alertmanager.Out, w http.ResponseWriter) {
if len(amo.Alerts) < 1 {
log.Debug().
Str(logging.FieldKeyCorrelationId, correlationId).
Msg("There are no alerts within this notification. There is nothing to forward to Discord. Returning early...")
w.WriteHeader(http.StatusOK)
return
}
logger := zerolog.New(os.Stderr).With().
Timestamp().
Str(logging.FieldKeyCorrelationId, correlationId).Logger()
if amo.CommonLabels.Alertname != "" {
logger = logger.With().Str(logging.FieldKeyAlertName, amo.CommonLabels.Alertname).Logger()
} else if amo.GroupLabels.Alertname != "" {
logger = logger.With().Str(logging.FieldKeyAlertName, amo.GroupLabels.Alertname).Logger()
}
failedToPublishAtLeastOne := false
for status, alerts := range af.groupAlerts(amo) {
DO := TranslateAlertManagerToDiscord(status, amo, alerts)
logger.Info().
Str(logging.FieldKeyEventType, logging.EventTypeRequestSending).
Str(logging.FieldKeyCorrelationId, correlationId).
Msg("Sending HTTP request to Discord.")
res, err := af.client.PublishMessage(DO)
if err != nil {
err = fmt.Errorf("failed to publish message to Discord: %w", err)
logger.Error().
Str(logging.FieldKeyCorrelationId, correlationId).
Err(err).
Msg("Error when attempting to publish message to Discord.")
failedToPublishAtLeastOne = true
continue
}
logger.Info().
Str(logging.FieldKeyEventType, logging.EventTypeResponseReceived).
Str(logging.FieldKeyCorrelationId, correlationId).
Msg("HTTP response received from Discord")
if res.StatusCode < 200 || res.StatusCode > 399 {
failedToPublishAtLeastOne = true
continue
}
}
if failedToPublishAtLeastOne {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (af *AlertForwarder) sendRawPromAlertWarn(correlationId string) (*http.Response, error) {
warningMessage := `You have probably misconfigured this software.
We detected input in Prometheus Alert format but are expecting AlertManager format.
This program is intended to ingest alerts from alertmanager.
It is not a replacement for alertmanager, it is a
webhook target for it. Please read the README.md
for guidance on how to configure it for alertmanager
or https://prometheus.io/docs/alerting/latest/configuration/#webhook_config`
log.Warn().Msg(warningMessage)
DO := discord.Out{
Content: "",
Embeds: []discord.Embed{
{
Title: "You have misconfigured this software",
Description: warningMessage,
Color: discord.ColorGrey,
Fields: []discord.EmbedField{},
},
},
}
log.Info().
Str(logging.FieldKeyEventType, logging.EventTypeRequestSending).
Str(logging.FieldKeyCorrelationId, correlationId).
Msg("Sending HTTP request to Discord.")
res, err := af.client.PublishMessage(DO)
if err != nil {
return nil, fmt.Errorf("error encountered when publishing message to Discord: %w", err)
}
log.Info().
Str(logging.FieldKeyEventType, logging.EventTypeResponseReceived).
Str(logging.FieldKeyCorrelationId, correlationId).
Msg("HTTP response received from Discord")
return res, nil
}
func (af *AlertForwarder) TransformAndForward(w http.ResponseWriter, r *http.Request) {
correlationId := uuid.New().String()
log.Info().
Str(logging.FieldKeyHttpHost, r.Host).
Str(logging.FieldKeyHttpMethod, r.Method).
Str(logging.FieldKeyHttpPath, r.URL.Path).
Str(logging.FieldKeyEventType, logging.EventTypeRequestReceived).
Str(logging.FieldKeyCorrelationId, correlationId).
Msg("HTTP request received from AlertManager.")
defer log.Info().
Str(logging.FieldKeyEventType, logging.EventTypeResponseSending).
Str(logging.FieldKeyCorrelationId, correlationId).
Msg("Sending HTTP response to AlertManager.")
b, err := io.ReadAll(r.Body)
if err != nil {
log.Error().
Str(logging.FieldKeyCorrelationId, correlationId).
Err(err).
Msg("Unable to read request body.")
w.WriteHeader(http.StatusInternalServerError)
return
}
amo := alertmanager.Out{}
err = json.Unmarshal(b, &amo)
if err != nil {
af.handleInvalidInput(correlationId, b, w)
return
}
af.sendWebhook(correlationId, &amo, w)
}
func (af *AlertForwarder) handleInvalidInput(correlationId string, b []byte, w http.ResponseWriter) {
if prometheus.IsAlert(b) {
log.Info().
Str(logging.FieldKeyCorrelationId, correlationId).
Msg("Detected a Prometheus Alert, and not an AlertManager alert, has been sent within the http request. This indicates a misconfiguration. Attempting to send a message to notify the Discord channel of the misconfiguration.")
res, err := af.sendRawPromAlertWarn(correlationId)
if err != nil || (res != nil && res.StatusCode < 200 || res.StatusCode > 399) {
statusCode := 0
if res != nil {
statusCode = res.StatusCode
}
log.Error().
Err(err).
Str(logging.FieldKeyCorrelationId, correlationId).
Int(logging.FieldKeyStatusCode, statusCode).
Msg("Error when attempting to send a warning message to Discord.")
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusUnprocessableEntity)
return
}
if len(b) > maxLogLength-3 {
log.Info().
Str(logging.FieldKeyCorrelationId, correlationId).
Msgf("Failed to unpack inbound alert request - %s...", string(b[:maxLogLength-3]))
} else {
log.Info().
Str(logging.FieldKeyCorrelationId, correlationId).
Msgf("Failed to unpack inbound alert request - %s", string(b))
}
w.WriteHeader(http.StatusBadRequest)
}
+366
View File
@@ -0,0 +1,366 @@
package alertforwarder
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/specklesystems/alertmanager-discord/pkg/alertmanager"
"github.com/specklesystems/alertmanager-discord/pkg/discord"
"github.com/specklesystems/alertmanager-discord/pkg/prometheus"
. "github.com/specklesystems/alertmanager-discord/test"
"github.com/stretchr/testify/assert"
)
func Test_TransformAndForward_HappyPath(t *testing.T) {
ao := alertmanager.Out{
Alerts: []alertmanager.Alert{
{
Status: alertmanager.StatusFiring,
},
},
CommonAnnotations: struct {
Summary string `json:"summary"`
}{
Summary: "a_common_annotation_summary",
},
}
mockClientRecorder, res := triggerAndRecordRequest(t, ao, http.StatusOK)
defer res.Body.Close()
assert.Equal(t, http.StatusOK, res.StatusCode, "http response status code")
assert.Equal(t, 1, len(mockClientRecorder.Requests), "Should have sent one request to Discord")
assert.Equal(t, "application/json", mockClientRecorder.Requests[0].ContentType, "content type")
do := readerToDiscordOut(t, mockClientRecorder.Requests[0].Body)
assert.Equal(t, 1, len(do.Embeds), "Discord message embed length")
assert.Equal(t, 10038562, do.Embeds[0].Color, "Discord message embed color")
assert.Contains(t, do.Content, "a_common_annotation_summary", "Discord message content")
}
func Test_TransformAndForward_InvalidInput_NoValue_ReturnsErrorResponseCode(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(""))
req.Host = "testing.localhost"
mockClientRecorder := MockClientRecorder{}
mockClient := mockClientRecorder.NewMockClientWithResponse(http.StatusBadRequest)
SUT := NewAlertForwarder(mockClient, "https://discordapp.com/api/webhooks/123456789123456789/abc", 100*time.Millisecond)
w := httptest.NewRecorder()
SUT.TransformAndForward(w, req)
res := w.Result()
defer res.Body.Close()
assert.Equal(t, http.StatusBadRequest, res.StatusCode, "Should expect an http response status code indicating request was bad.")
assert.Equal(t, 0, len(mockClientRecorder.Requests), "should not have sent a request to Discord")
}
func Test_TransformAndForward_InvalidInput_LongString_ReturnsErrorResponseCode(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("a", 1025)))
req.Host = "testing.localhost"
mockClientRecorder := MockClientRecorder{}
mockClient := mockClientRecorder.NewMockClientWithResponse(http.StatusBadRequest)
SUT := NewAlertForwarder(mockClient, "https://discordapp.com/api/webhooks/123456789123456789/abc", 100*time.Millisecond)
w := httptest.NewRecorder()
SUT.TransformAndForward(w, req)
res := w.Result()
defer res.Body.Close()
assert.Equal(t, http.StatusBadRequest, res.StatusCode, "Should expect an http response status code indicating request was bad.")
assert.Equal(t, 0, len(mockClientRecorder.Requests), "should not have sent a request to Discord")
}
func Test_TransformAndForward_InvalidInput_PrometheusAlert_ReturnsErrorResponseCode(t *testing.T) {
promAlert := []prometheus.Alert{
{
Status: "",
},
}
promAlertJson, err := json.Marshal(promAlert)
assert.NoError(t, err, "marshalling prometheus alert")
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(promAlertJson))
req.Host = "testing.localhost"
mockClientRecorder := MockClientRecorder{}
mockClient := mockClientRecorder.NewMockClientWithResponse(http.StatusBadRequest)
SUT := NewAlertForwarder(mockClient, "https://discordapp.com/api/webhooks/123456789123456789/abc", 100*time.Millisecond)
w := httptest.NewRecorder()
SUT.TransformAndForward(w, req)
res := w.Result()
defer res.Body.Close()
assert.Equal(t, http.StatusInternalServerError, res.StatusCode, "Should expect an http response status code indicating server internal error.")
assert.Equal(t, 1, len(mockClientRecorder.Requests), "should have sent one request to Discord (with a message stating there is a problem)")
// TODO test message content sent to Discord
}
// FIXME may not be able to simulate error in http Client?
func Test_TransformAndForward_PrometheusAlert_And_DiscordClientResponsdsWithError_RespondsWithErrorCode(t *testing.T) {
promAlert := []prometheus.Alert{
{
Status: "",
},
}
promAlertJson, err := json.Marshal(promAlert)
assert.NoError(t, err, "marshalling prometheus alert")
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(promAlertJson))
req.Host = "testing.localhost"
mockClientRecorder := MockClientRecorder{}
mockClient := mockClientRecorder.NewMockClientReturnsNil()
SUT := NewAlertForwarder(mockClient, "https://discordapp.com/api/webhooks/123456789123456789/abc", 100*time.Millisecond)
w := httptest.NewRecorder()
SUT.TransformAndForward(w, req)
res := w.Result()
defer res.Body.Close()
assert.Equal(t, http.StatusInternalServerError, res.StatusCode, "Should expect an http response status code indicating request was unprocessable.")
assert.GreaterOrEqual(t, len(mockClientRecorder.Requests), 1, "should have sent at least one request to Discord (with a message stating there is a problem)")
// TODO test message content sent to Discord
}
func Test_TransformAndForward_PrometheusAlert_And_DiscordClientResponsdsWithErrorStatusCode_RespondsWithErrorStatusCode(t *testing.T) {
promAlert := []prometheus.Alert{
{
Status: "",
},
}
promAlertJson, err := json.Marshal(promAlert)
assert.NoError(t, err, "marshalling prometheus alert")
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(promAlertJson))
req.Host = "testing.localhost"
mockClientRecorder := MockClientRecorder{}
mockClient := mockClientRecorder.NewMockClientWithResponse(http.StatusBadRequest)
SUT := NewAlertForwarder(mockClient, "https://discordapp.com/api/webhooks/123456789123456789/abc", 100*time.Millisecond)
w := httptest.NewRecorder()
SUT.TransformAndForward(w, req)
res := w.Result()
defer res.Body.Close()
assert.Equal(t, http.StatusInternalServerError, res.StatusCode, "Should expect an http response status code indicating internal server error.")
assert.Equal(t, 1, len(mockClientRecorder.Requests), "should have sent a request to Discord (with a message stating there is a problem)")
// TODO test message content sent to Discord
}
func Test_TransformAndForward_NoAlerts_DoesNotSendToDiscord(t *testing.T) {
ao := alertmanager.Out{}
mockClientRecorder, res := triggerAndRecordRequest(t, ao, http.StatusBadRequest)
defer res.Body.Close()
assert.Equal(t, http.StatusOK, res.StatusCode, "http response status code")
assert.Equal(t, 0, len(mockClientRecorder.Requests), "mock client should not be triggered")
}
func Test_TransformAndForward_NoCommonAnnotationSummary_HappyPath(t *testing.T) {
ao := alertmanager.Out{
Alerts: []alertmanager.Alert{
{
Status: alertmanager.StatusFiring,
},
},
}
mockClientRecorder, res := triggerAndRecordRequest(t, ao, http.StatusOK)
defer res.Body.Close()
assert.Equal(t, http.StatusOK, res.StatusCode, "http response status code")
assert.Equal(t, 1, len(mockClientRecorder.Requests), "mock client should be triggered")
assert.Equal(t, "application/json", mockClientRecorder.Requests[0].ContentType, "content type")
do := readerToDiscordOut(t, mockClientRecorder.Requests[0].Body)
assert.Equal(t, 1, len(do.Embeds), "Discord message embed length")
assert.Equal(t, 10038562, do.Embeds[0].Color, "Discord message embed color")
assert.Equal(t, "", do.Content, "Discord message content")
}
func Test_TransformAndForward_StatusResolved_HappyPath(t *testing.T) {
ao := alertmanager.Out{
Alerts: []alertmanager.Alert{
{
Status: alertmanager.StatusResolved,
},
},
}
mockClientRecorder, res := triggerAndRecordRequest(t, ao, http.StatusOK)
defer res.Body.Close()
assert.Equal(t, http.StatusOK, res.StatusCode, "http response status code")
assert.Equal(t, 1, len(mockClientRecorder.Requests), "mock client should be triggered")
do := readerToDiscordOut(t, mockClientRecorder.Requests[0].Body)
assert.Equal(t, 1, len(do.Embeds), "Discord message embed length")
assert.Equal(t, 3066993, do.Embeds[0].Color, "Discord message embed color")
}
// alert with a label 'instance'='localhost' and 'exported_instance' label is set, should have the instance replaced by 'exported_instance'
func Test_TransformAndForward_Annotations_AreAddedAsEmbedFields_HappyPath(t *testing.T) {
ao := alertmanager.Out{
Alerts: []alertmanager.Alert{
{
Status: alertmanager.StatusFiring,
Annotations: map[string]string{
"environment": "development",
},
},
},
}
mockClientRecorder, res := triggerAndRecordRequest(t, ao, http.StatusOK)
defer res.Body.Close()
assert.Equal(t, http.StatusOK, res.StatusCode, "http response status code")
assert.Equal(t, 1, len(mockClientRecorder.Requests), "mock client should be triggered")
assert.Equal(t, "application/json", mockClientRecorder.Requests[0].ContentType, "content type")
do := readerToDiscordOut(t, mockClientRecorder.Requests[0].Body)
assert.Equal(t, 1, len(do.Embeds), "Discord message embed length")
assert.Equal(t, 10038562, do.Embeds[0].Color, "Discord message embed color")
assert.Equal(t, 1, len(do.Embeds[0].Fields), "Discord message embed fields length")
assert.Contains(t, do.Embeds[0].Fields[0].Value, "development", "Discord message embed field Name should contain attribute")
assert.Equal(t, "", do.Content, "Discord message content")
}
// FIXME may not be able to create an error in http.Client
// Discord client returns an error (e.g. a closed connection, network outage or similar)
func Test_TransformAndForward_DiscordClientReturnsError(t *testing.T) {
ao := alertmanager.Out{
Alerts: []alertmanager.Alert{
{
Status: alertmanager.StatusFiring,
},
},
CommonAnnotations: struct {
Summary string `json:"summary"`
}{
Summary: "a_common_annotation_summary",
},
}
mockClientRecorder, res := triggerAndRecordRequest(t, ao, http.StatusBadRequest)
defer res.Body.Close()
assert.Equal(t, http.StatusInternalServerError, res.StatusCode, "http response status code")
assert.GreaterOrEqual(t, len(mockClientRecorder.Requests), 1, "Should have sent a request to Discord")
assert.Equal(t, "application/json", mockClientRecorder.Requests[0].ContentType, "content type")
do := readerToDiscordOut(t, mockClientRecorder.Requests[0].Body)
assert.Equal(t, 1, len(do.Embeds), "Discord message embed length")
assert.Equal(t, 10038562, do.Embeds[0].Color, "Discord message embed color")
assert.Contains(t, do.Content, "a_common_annotation_summary", "Discord message content")
}
func Test_TransformAndForward_DiscordReturnsWithErrorStatusCode_ReturnInternalServerErrorStatusCode(t *testing.T) {
ao := alertmanager.Out{
Alerts: []alertmanager.Alert{
{
Status: alertmanager.StatusFiring,
},
},
CommonAnnotations: struct {
Summary string `json:"summary"`
}{
Summary: "a_common_annotation_summary",
},
}
mockClientRecorder, res := triggerAndRecordRequest(t, ao, http.StatusUnauthorized)
defer res.Body.Close()
assert.Equal(t, 1, len(mockClientRecorder.Requests), "Should have sent a request to Discord")
assert.Equal(t, "application/json", mockClientRecorder.Requests[0].ContentType, "content type")
assert.Equal(t, http.StatusInternalServerError, res.StatusCode, "http response status code should be 500")
}
// TODO Add a test for context with multiple alerts: if some are firing and some resolved we should publish two separate messages to Discord - alerts with matching statuses should be grouped together
func Test_TransformAndForward_MultipleAlerts_DifferentStatus_HappyPath(t *testing.T) {
ao := alertmanager.Out{
Alerts: []alertmanager.Alert{
{
Status: alertmanager.StatusFiring,
},
{
Status: alertmanager.StatusFiring,
},
{
Status: alertmanager.StatusResolved,
},
},
}
mockClientRecorder, res := triggerAndRecordRequest(t, ao, http.StatusOK)
defer res.Body.Close()
assert.Equal(t, http.StatusOK, res.StatusCode, "http response status code")
assert.Equal(t, 2, len(mockClientRecorder.Requests), "Should have sent two requests to Discord")
}
// HELPERS
func triggerAndRecordRequest(t *testing.T, request alertmanager.Out, discordStatusCode int) (mockClientRecorder MockClientRecorder, httpResponse *http.Response) {
aoJson, err := json.Marshal(request)
assert.NoError(t, err, "marshalling alertmanager out")
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(aoJson))
req.Host = "testing.localhost"
mockClientRecorder = MockClientRecorder{}
mockClient := mockClientRecorder.NewMockClientWithResponse(discordStatusCode)
SUT := NewAlertForwarder(mockClient, "https://discordapp.com/api/webhooks/123456789123456789/abc", 100*time.Millisecond)
w := httptest.NewRecorder()
SUT.TransformAndForward(w, req)
httpResponse = w.Result()
return mockClientRecorder, httpResponse
}
func readerToDiscordOut(t *testing.T, reader io.Reader) discord.Out {
buf := new(bytes.Buffer)
buf.ReadFrom(reader)
do := discord.Out{}
err := json.Unmarshal(buf.Bytes(), &do)
if err != nil {
t.Errorf("Unexpected error marshalling to Discord Object from the Discord client request body.")
}
return do
}
+41
View File
@@ -0,0 +1,41 @@
package alertforwarder
import (
"fmt"
"net"
"net/url"
"regexp"
"strings"
"github.com/specklesystems/alertmanager-discord/pkg/flags"
)
func CheckWebhookURL(webhookURL string) (bool, *url.URL, error) {
if webhookURL == "" {
return false, &url.URL{}, fmt.Errorf("'%s' has not been set. This can be provided via configuration file, command line, or environment variable ('%s')", flags.DiscordWebhookUrlFlagKey, strings.ToUpper(flags.DiscordWebhookUrlFlagKey))
}
parsedUrl, err := url.Parse(webhookURL)
if err != nil {
return false, &url.URL{}, fmt.Errorf("the Discord WebHook URL ('%s') cannot be parsed as a url: %w", webhookURL, err)
}
host, _, _ := net.SplitHostPort(parsedUrl.Host)
if host == "" {
host = parsedUrl.Host
}
// localhost is allowed, for testing or for proxied routes etc..
if host == "127.0.0.1" || host == "::1" || host == "localhost" {
return true, parsedUrl, nil
}
re := regexp.MustCompile(`https://discord(?:app)?.com/api/webhooks/[0-9]{18,19}/[a-zA-Z0-9_-]+`)
ok := re.Match([]byte(webhookURL))
if !ok {
return false, parsedUrl, fmt.Errorf("the Discord WebHook URL doesn't seem to be a valid Discord Webhook API url: '%s'", webhookURL)
}
return ok, parsedUrl, nil
}
@@ -0,0 +1,45 @@
package alertforwarder
import (
"github.com/stretchr/testify/assert"
"testing"
)
func Test_WebhookUrl_HappyPath(t *testing.T) {
ok, _, _ := CheckWebhookURL("https://discordapp.com/api/webhooks/123456789123456789/abc")
assert.True(t, ok, "Should be a valid webhook url")
ok, _, _ = CheckWebhookURL("https://discord.com/api/webhooks/123456789123456789/abc")
assert.True(t, ok, "Should be a valid webhook url")
ok, _, _ = CheckWebhookURL("http://localhost/")
assert.True(t, ok, "Should be a valid webhook url")
ok, _, _ = CheckWebhookURL("http://127.0.0.1/")
assert.True(t, ok, "Should be a valid webhook url")
ok, _, _ = CheckWebhookURL("http://::1/")
assert.True(t, ok, "Should be a valid webhook url")
}
func Test_WebhookUrl_EmptyUrl_ReturnsFalse(t *testing.T) {
ok, _, err := CheckWebhookURL("")
assert.False(t, ok, "Empty url should be identified as invalid")
assert.Error(t, err, "Empty url should return an error message")
}
func Test_WebhookUrl_InvalidUrl_ReturnsFalse(t *testing.T) {
ok, _, err := CheckWebhookURL("::::::::::")
assert.False(t, ok, "Malformed urls should be identified as invalid")
assert.Error(t, err, "Invalid url should return an error message")
}
func Test_WebhookUrl_InvalidAPIUrl_ReturnsFalse(t *testing.T) {
ok, _, err := CheckWebhookURL("https://discordapp.com/api/webhooks/12/abc")
assert.False(t, ok, "Malformed Discord API urls should be identified as invalid")
assert.Error(t, err, "Malformed Discord API url should return an error message")
ok, _, err = CheckWebhookURL("https://example.org/api/webhooks/12/abc")
assert.False(t, ok, "Non-Discord urls should be identified as invalid")
assert.Error(t, err, "Non-Discord urls should return an error message")
}
+89
View File
@@ -0,0 +1,89 @@
package alertforwarder
import (
"fmt"
"sort"
"strings"
"github.com/specklesystems/alertmanager-discord/pkg/alertmanager"
"github.com/specklesystems/alertmanager-discord/pkg/discord"
)
const (
keySummary = "summary"
keyEnvironmentType = "source_environment_type"
keyEnvironmentName = "source_environment_name"
)
func TranslateAlertManagerToDiscord(status string, amo *alertmanager.Out, alerts []alertmanager.Alert) discord.Out {
DO := discord.Out{}
if amo.CommonAnnotations.Summary != "" {
DO.Content = fmt.Sprintf(" === %s === \n", amo.CommonAnnotations.Summary)
}
RichEmbed := discord.Embed{
Title: fmt.Sprintf("[%s: %d] %s", strings.ToUpper(status), len(alerts), amo.CommonLabels.Alertname),
Description: amo.CommonAnnotations.Summary,
Color: discord.ColorGrey,
Fields: []discord.EmbedField{},
}
switch status {
case alertmanager.StatusFiring:
RichEmbed.Color = discord.ColorRed
case alertmanager.StatusResolved:
RichEmbed.Color = discord.ColorGreen
}
for _, alert := range alerts {
fieldName := fmt.Sprintf("[%s/%s] Alert details", alert.Labels[keyEnvironmentType], alert.Labels[keyEnvironmentName])
if summary, ok := alert.Annotations[keySummary]; ok {
fieldName = fmt.Sprintf("[%s/%s] %s", alert.Labels[keyEnvironmentType], alert.Labels[keyEnvironmentName], summary)
}
var details strings.Builder
details.WriteString("Annotations:\n")
// sort into alphabetical order
annotationKeys := make([]string, 0, len(alert.Annotations))
for key := range alert.Annotations {
annotationKeys = append(annotationKeys, key)
}
sort.Strings(annotationKeys)
for _, key := range annotationKeys {
if key == keySummary {
// if there is a summary, it is already the field name so no need to repeat it
continue
}
details.WriteString(fmt.Sprintf("\t%s: %s\n", key, alert.Annotations[key]))
}
details.WriteString("Labels:\n")
// sort into alphabetical order
labelKeys := make([]string, 0, len(alert.Labels))
for key := range alert.Labels {
labelKeys = append(labelKeys, key)
}
sort.Strings(labelKeys)
for _, key := range labelKeys {
if key == keyEnvironmentName || key == keyEnvironmentType {
// if these keys exist, we have already added them to the field name
continue
}
details.WriteString(fmt.Sprintf("\t%s: %s\n", key, alert.Labels[key]))
}
RichEmbed.Fields = append(RichEmbed.Fields, discord.EmbedField{
Name: fieldName,
Value: details.String(),
})
}
DO.Embeds = []discord.Embed{RichEmbed}
return DO
}
+33
View File
@@ -0,0 +1,33 @@
package alertmanager
const (
StatusFiring = "firing"
StatusResolved = "resolved"
)
type Alert struct {
Annotations map[string]string `json:"annotations"`
EndsAt string `json:"endsAt"`
GeneratorURL string `json:"generatorURL"`
Labels map[string]string `json:"labels"`
StartsAt string `json:"startsAt"`
Status string `json:"status"`
}
type Out struct {
Alerts []Alert `json:"alerts"`
CommonAnnotations struct {
Summary string `json:"summary"`
} `json:"commonAnnotations"`
CommonLabels struct {
Alertname string `json:"alertname"`
} `json:"commonLabels"`
ExternalURL string `json:"externalURL"`
GroupKey string `json:"groupKey"`
GroupLabels struct {
Alertname string `json:"alertname"`
} `json:"groupLabels"`
Receiver string `json:"receiver"`
Status string `json:"status"`
Version string `json:"version"`
}
+75
View File
@@ -0,0 +1,75 @@
package discord
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
backoff "github.com/cenkalti/backoff/v4"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
const (
DefaultMaximumBackoffElapsedTime = 10 * time.Second
)
type Client struct {
httpClient *http.Client
URL string
maximumBackoffElapsedTime time.Duration
}
func NewClient(client *http.Client, url string, maximumBackoffElapsedTime time.Duration) *Client {
if maximumBackoffElapsedTime <= 0 {
maximumBackoffElapsedTime = DefaultMaximumBackoffElapsedTime
}
underlyingTransport := http.DefaultTransport
if client.Transport != nil {
underlyingTransport = client.Transport
}
// wrap instrumentation around the existing http.Client transport
client.Transport = promhttp.InstrumentRoundTripperInFlight(RequestsToDiscordInFlight,
promhttp.InstrumentRoundTripperCounter(RequestsToDiscordTotal,
promhttp.InstrumentRoundTripperDuration(RequestsToDiscordDuration,
underlyingTransport,
),
),
)
return &Client{
httpClient: client,
URL: url,
maximumBackoffElapsedTime: maximumBackoffElapsedTime,
}
}
func (dc *Client) PublishMessage(message Out) (*http.Response, error) {
DOD, err := json.Marshal(message)
if err != nil {
return nil, fmt.Errorf("Error encountered when marshalling object to json. We will not continue posting to Discord. Discord Out object: '%v+'. Error: %w", message, err)
}
var response *http.Response
operation := func() error {
res, err := dc.httpClient.Post(dc.URL, "application/json", bytes.NewReader(DOD))
if err == nil {
response = res
}
return err
}
exponential := backoff.NewExponentialBackOff()
exponential.MaxElapsedTime = dc.maximumBackoffElapsedTime
err = backoff.Retry(operation, exponential)
if err != nil {
return nil, fmt.Errorf("Error encountered sending POST to '%s'. Error: %w", dc.URL, err)
}
return response, nil
}
+24
View File
@@ -0,0 +1,24 @@
package discord
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
RequestsToDiscordInFlight = promauto.NewGauge(prometheus.GaugeOpts{
Name: "discord_client_requests_in_flight",
Help: "The current number of http requests being sent by the Discord client.",
})
RequestsToDiscordTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "discord_client_requests_total",
Help: "The total number of http requests sent by the Discord client.",
}, []string{"code", "method"})
RequestsToDiscordDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "discord_client_request_duration_seconds",
Help: "Duration of all http requests sent by the Discord client.",
Buckets: prometheus.DefBuckets,
}, []string{"code"})
)
+25
View File
@@ -0,0 +1,25 @@
package discord
// Discord color values
const (
ColorRed = 0x992D22
ColorGreen = 0x2ECC71
ColorGrey = 0x95A5A6
)
type Out struct {
Content string `json:"content"`
Embeds []Embed `json:"embeds"`
}
type Embed struct {
Title string `json:"title"`
Description string `json:"description"`
Color int `json:"color"`
Fields []EmbedField `json:"fields"`
}
type EmbedField struct {
Name string `json:"name"`
Value string `json:"value"`
}
+9
View File
@@ -0,0 +1,9 @@
package flags
const (
ConfigurationPathFlagKey = "configuration_file_path"
DiscordWebhookUrlFlagKey = "discord_webhook_url"
ListenAddressFlagKey = "listen_address"
MaxBackoffTimeSecondsFlagKey = "max_backoff_time_seconds"
LogLevelFlagKey = "log_level"
)
+9
View File
@@ -0,0 +1,9 @@
package logging
const (
// field values should be lowercase snake_case where possible
EventTypeRequestReceived = "received_request"
EventTypeRequestSending = "sending_request"
EventTypeResponseReceived = "received_response"
EventTypeResponseSending = "sending_response"
)
+12
View File
@@ -0,0 +1,12 @@
package logging
const (
// field keys should be lowercase snake_case.
FieldKeyHttpHost = "host"
FieldKeyHttpMethod = "method"
FieldKeyHttpPath = "path"
FieldKeyEventType = "event_type"
FieldKeyAlertName = "alert_name"
FieldKeyCorrelationId = "correlation_id"
FieldKeyStatusCode = "status_code"
)
+24
View File
@@ -0,0 +1,24 @@
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
RequestsToAlertForwarderInFlight = promauto.NewGauge(prometheus.GaugeOpts{
Name: "alertmanager_discord_alert_forwarder_requests_in_flight",
Help: "The current number of events being processed by alert forwarder.",
})
RequestsToAlertForwarderTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "alertmanager_discord_alert_forwarder_requests_total",
Help: "The total number of http requests processed by alert forwarder.",
}, []string{"code"})
RequestsToAlertForwarderDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "alertmanager_discord_alert_forwarder_request_duration_seconds",
Help: "Duration of all http requests processed by alert forwarder.",
Buckets: prometheus.DefBuckets,
}, []string{"code"})
)
+32
View File
@@ -0,0 +1,32 @@
package prometheus
import (
"encoding/json"
)
type Alert struct {
Annotations struct {
Description string `json:"description"`
Summary string `json:"summary"`
} `json:"annotations"`
EndsAt string `json:"endsAt"`
GeneratorURL string `json:"generatorURL"`
Labels map[string]string `json:"labels"`
StartsAt string `json:"startsAt"`
Status string `json:"status"`
}
func IsAlert(b []byte) bool {
alertTest := make([]Alert, 0)
err := json.Unmarshal(b, &alertTest)
if err == nil {
if len(alertTest) != 0 {
if alertTest[0].Status == "" {
// Ok it's more than likely then
return true
}
}
}
return false
}
+136
View File
@@ -0,0 +1,136 @@
//go:build !unit
// +build !unit
package server
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/specklesystems/alertmanager-discord/pkg/alertmanager"
"github.com/stretchr/testify/assert"
)
const (
serverListenAddress = "127.0.0.1:9096"
)
func Test_Serve_HappyPath(t *testing.T) {
// create a mock Discord server to respond to our request
receivedRequest := make(chan bool, 1)
mockDiscordServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Discord mock server will always return with Status Code 200 OK
receivedRequest <- true // notify the channel that the Discord server received the request
}))
defer mockDiscordServer.Close()
amds := AlertManagerDiscordServer{}
defer func() {
err := amds.Shutdown()
assert.NoError(t, err, "server shutdown should not error")
}()
_, err := amds.ListenAndServe(mockDiscordServer.URL, serverListenAddress)
assert.NoError(t, err, "server ListenAndServe should not error")
client := http.Client{
Timeout: 500 * time.Millisecond,
}
res, err := client.Get(fmt.Sprintf("http://%s/liveness", serverListenAddress))
assert.NoError(t, err)
assert.NotNil(t, res, "response to GET '/liveness' should not be nil")
assert.Equal(t, http.StatusOK, res.StatusCode, "GET liveness should return status code OK (200)")
res, err = client.Get(fmt.Sprintf("http://%s/readiness", serverListenAddress))
assert.NoError(t, err)
assert.NotNil(t, res, "response to GET '/readiness' should not be nil")
assert.Equal(t, http.StatusOK, res.StatusCode, "GET readiness should return status code OK (200)")
res, err = client.Get(fmt.Sprintf("http://%s/favicon.ico", serverListenAddress))
assert.NoError(t, err)
assert.NotNil(t, res, "response to GET '/favicon.ico' should not be nil")
assert.Equal(t, http.StatusOK, res.StatusCode, "GET favicon.ico should return status code OK (200)")
res, err = client.Get(fmt.Sprintf("http://%s/metrics", serverListenAddress))
assert.NoError(t, err)
assert.NotNil(t, res, "response to GET '/metrics' should not be nil")
assert.Equal(t, http.StatusOK, res.StatusCode, "GET favicon.ico should return status code OK (200)")
// assert mock Discord server received expected json
ao := alertmanager.Out{
Alerts: []alertmanager.Alert{
{
Status: alertmanager.StatusFiring,
},
},
CommonAnnotations: struct {
Summary string `json:"summary"`
}{
Summary: "a_common_annotation_summary",
},
GroupLabels: struct {
Alertname string `json:"alertname"`
}{
Alertname: "testAlertName",
},
}
aoJson, err := json.Marshal(ao)
assert.NoError(t, err, "marshalling alertmanager out")
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://%s/", serverListenAddress), bytes.NewReader(aoJson))
assert.NoError(t, err, "creating http request")
req.Host = mockDiscordServer.URL
res, err = client.Do(req)
defer func() {
if res != nil && res.Body != nil {
res.Body.Close()
}
}()
assert.NoError(t, err, "sending request to alertmanager-discord server.")
assert.NotNil(t, res, "response to POST '/' should not be nil")
assert.Equal(t, http.StatusOK, res.StatusCode, "sending valid alertmanager data should expect http response status code")
assert.True(t, <-receivedRequest, "Mock Discord server should have received response") // will wait until the request is received
// TODO assert log lines were generated
// TODO assert prometheus metrics were generated
}
// Test with invalid URL, throws an error
func Test_Server_InvalidDiscordUrl(t *testing.T) {
amds := AlertManagerDiscordServer{}
defer func() {
err := amds.Shutdown()
assert.NoError(t, err, "server shutdown should not error")
}()
_, err := amds.ListenAndServe("https://example.org/not/a/discord/webhook/api", "127.0.0.1:9095")
assert.Error(t, err, "server ListenAndServe should return an error for an invalid url")
}
// // Commented out as some interaction with the Server_HappyPath test causes that to fail ~5% of runs
// func Test_Server_With_EmptyListenAddress_DefaultsToListenAddress(t *testing.T) {
// amds := AlertManagerDiscordServer{}
// defer func() {
// err := amds.Shutdown()
// NoError(t, err, "server shutdown should not error")
// }()
// _, err := amds.ListenAndServe("http://localhost/", "")
// NoError(t, err, "server ListenAndServe should not error")
// client := http.Client{
// Timeout: 500 * time.Millisecond,
// }
// // it should have defaulted to the default listen address
// res, err := client.Get(fmt.Sprintf("http://%s/liveness", "127.0.0.1:9094"))
// NotNil(t, res, "Response should not be nil")
// EqualInt(t, http.StatusOK, res.StatusCode, "Liveness probe should return status code OK (200)")
// }
+119
View File
@@ -0,0 +1,119 @@
package server
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"time"
"github.com/specklesystems/alertmanager-discord/pkg/alertforwarder"
"github.com/specklesystems/alertmanager-discord/pkg/metrics"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/rs/zerolog/log"
)
const (
DefaultListenAddress = "0.0.0.0:9094"
)
const (
FaviconPath = "/favicon.ico"
LivenessPath = "/liveness"
ReadinessPath = "/readiness"
)
type AlertManagerDiscordServer struct {
httpServer *http.Server
MaximumBackoffTimeSeconds time.Duration
}
func (amds *AlertManagerDiscordServer) ListenAndServe(webhookUrl, listenAddress string) (chan os.Signal, error) {
stop := make(chan os.Signal, 1)
mux := http.NewServeMux()
ok, _, err := alertforwarder.CheckWebhookURL(webhookUrl)
if !ok {
return stop, fmt.Errorf("url is invalid: %w", err)
}
if listenAddress == "" {
log.Info().Msgf("Listen address not provided. Using default: '%s'", DefaultListenAddress)
listenAddress = DefaultListenAddress
}
log.Info().Msgf("Listening on: %s", listenAddress)
discordClient := &http.Client{
Timeout: 5 * time.Second,
}
transformAndForwardWithInstrumentation := promhttp.InstrumentHandlerDuration(metrics.RequestsToAlertForwarderDuration,
promhttp.InstrumentHandlerCounter(metrics.RequestsToAlertForwarderTotal,
promhttp.InstrumentHandlerInFlight(metrics.RequestsToAlertForwarderInFlight,
alertforwarder.NewAlertForwarderHandler(discordClient,
webhookUrl,
amds.MaximumBackoffTimeSeconds,
),
),
),
)
mux.HandleFunc("/", transformAndForwardWithInstrumentation)
mux.HandleFunc("/readiness", func(w http.ResponseWriter, r *http.Request) {
log.Debug().Msg("Readiness probe encountered.")
})
mux.HandleFunc("/liveness", func(w http.ResponseWriter, r *http.Request) {
log.Debug().Msg("Liveness probe encountered.")
})
mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
// purposefully empty
})
mux.Handle("/metrics", promhttp.Handler())
amds.httpServer = &http.Server{
Addr: listenAddress,
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20,
}
// Setting up signal capturing
signal.Notify(stop, os.Interrupt)
go func() {
// check for nil prevents race condition if we have already shutdown the server before this goroutine attempts to start
if amds.httpServer != nil {
if err := amds.httpServer.ListenAndServe(); err != nil {
close(stop)
}
}
}()
return stop, nil
}
func (amds *AlertManagerDiscordServer) Shutdown() error {
log.Info().Msg("Received signal to shut down server. Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if amds.httpServer == nil {
// http server is not referenced, or was never created, so we're unable to shut it down
return nil
}
if err := amds.httpServer.Shutdown(ctx); err != nil {
// prevent race condition if shutdown signal was sent prior to server starting, we remove server reference to prevent it starting
amds.httpServer = nil
return err
}
// prevent race condition if shutdown signal was sent prior to server starting, we remove server remove to prevent it starting
amds.httpServer = nil
return nil
}
+5
View File
@@ -0,0 +1,5 @@
package version
var (
Version = "0.0.0"
)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-6
View File
@@ -1,6 +0,0 @@
repositoryID: 970959ac-e2fa-44a7-955d-4fabe93fa253
owners:
- name: SpeckleDevops
ignore:
- name: alertmanager-discord
version: .*alpha.*
-204
View File
@@ -1,204 +0,0 @@
apiVersion: v1
entries:
alertmanager-discord:
- apiVersion: v2
appVersion: 0.3.2
created: "2024-06-18T07:58:23.882557595Z"
description: A Helm chart to deploy alertmanager-discord to Kubernetes
digest: d68756563716ac4b50e31773e25d6f47cdd9c11b957873cdda295d7fcb47f7aa
name: alertmanager-discord
type: application
urls:
- alertmanager-discord-0.3.2.tgz
version: 0.3.2
- apiVersion: v2
appVersion: 0.3.2-alpha.330
created: "2024-06-18T07:58:23.881828426Z"
description: A Helm chart to deploy alertmanager-discord to Kubernetes
digest: 2e99ba537eab1e6768a15f145414058c18b79073ff4767edcd18201b7639ca87
name: alertmanager-discord
type: application
urls:
- alertmanager-discord-0.3.2-alpha.330.tgz
version: 0.3.2-alpha.330
- apiVersion: v2
appVersion: 0.3.2-alpha.318
created: "2024-06-18T07:58:23.881120375Z"
description: A Helm chart to deploy alertmanager-discord to Kubernetes
digest: e0964cdb347be771761d7132f5375525e33eef222d2ee1f1a32a0c6f1b6dfbf5
name: alertmanager-discord
type: application
urls:
- alertmanager-discord-0.3.2-alpha.318.tgz
version: 0.3.2-alpha.318
- apiVersion: v2
appVersion: 0.3.2-alpha.307
created: "2024-06-18T07:58:23.880315763Z"
description: A Helm chart to deploy alertmanager-discord to Kubernetes
digest: 10f27530cc06b8a8e3e576db3b668289606ebc17d675396f6e4f47eaa0814b30
name: alertmanager-discord
type: application
urls:
- alertmanager-discord-0.3.2-alpha.307.tgz
version: 0.3.2-alpha.307
- apiVersion: v2
appVersion: 0.3.1
created: "2024-06-18T07:58:23.879536262Z"
description: A Helm chart to deploy alertmanager-discord to Kubernetes
digest: a5017b6f1a7f8f204e0239b80c379bc756e636177eb89c00320b64235f2fff1e
name: alertmanager-discord
type: application
urls:
- alertmanager-discord-0.3.1.tgz
version: 0.3.1
- apiVersion: v2
appVersion: 0.3.1-alpha.293
created: "2024-06-18T07:58:23.878764926Z"
description: A Helm chart to deploy alertmanager-discord to Kubernetes
digest: effc1fb7ad6a775a5ec24925082debd9a8347fa347876ea6b06d1730b2043ef3
name: alertmanager-discord
type: application
urls:
- alertmanager-discord-0.3.1-alpha.293.tgz
version: 0.3.1-alpha.293
- apiVersion: v2
appVersion: 0.3.1-alpha.281
created: "2024-06-18T07:58:23.87807261Z"
description: A Helm chart to deploy alertmanager-discord to Kubernetes
digest: b3ead27ac560c2a0e1d15c44ff05e36f9ee524fdbed18a211032f46020ba630c
name: alertmanager-discord
type: application
urls:
- alertmanager-discord-0.3.1-alpha.281.tgz
version: 0.3.1-alpha.281
- apiVersion: v2
appVersion: 0.3.0
created: "2024-06-18T07:58:23.877341303Z"
description: A Helm chart to deploy alertmanager-discord to Kubernetes
digest: 56f217638a5b19f408a5f12b026aa0afaec1839feb5ec034e42d59a8c8937ca4
name: alertmanager-discord
type: application
urls:
- alertmanager-discord-0.3.0.tgz
version: 0.3.0
- apiVersion: v2
appVersion: 0.2.7-alpha.269
created: "2024-06-18T07:58:23.87656442Z"
description: A Helm chart to deploy alertmanager-discord to Kubernetes
digest: 4b59471bcfe728b3bac38ff2e7934c8763c6be17a934930cca69b77a3e458671
name: alertmanager-discord
type: application
urls:
- alertmanager-discord-0.2.7-alpha.269.tgz
version: 0.2.7-alpha.269
- apiVersion: v2
appVersion: 0.2.6
created: "2024-06-18T07:58:23.875842782Z"
description: A Helm chart to deploy alertmanager-discord to Kubernetes
digest: be8a9501623295ef3aae817e08d4030c3aa732b8132b2d516135a3c54ee66447
name: alertmanager-discord
type: application
urls:
- alertmanager-discord-0.2.6.tgz
version: 0.2.6
- apiVersion: v2
appVersion: 0.2.6-alpha.256
created: "2024-06-18T07:58:23.875050193Z"
description: A Helm chart to deploy alertmanager-discord to Kubernetes
digest: 6dba71521444d9015c70e9f587f21f0a65ecffedeac6f5d5db608d9bd0aacf45
name: alertmanager-discord
type: application
urls:
- alertmanager-discord-0.2.6-alpha.256.tgz
version: 0.2.6-alpha.256
- apiVersion: v2
appVersion: 0.2.5
created: "2024-06-18T07:58:23.87369352Z"
description: A Helm chart to deploy alertmanager-discord to Kubernetes
digest: f08ceb39fafb9c88c547db67877bc93db73cf7179fcdd9f90b7a2d249e979c50
name: alertmanager-discord
type: application
urls:
- alertmanager-discord-0.2.5.tgz
version: 0.2.5
- apiVersion: v2
appVersion: 0.2.5-alpha.241
created: "2024-06-18T07:58:23.873181011Z"
description: A Helm chart to deploy alertmanager-discord to Kubernetes
digest: bae9ba18ee9132d76826c75c8e37168d01122508df75281febb2dd446e3b24b1
name: alertmanager-discord
type: application
urls:
- alertmanager-discord-0.2.5-alpha.241.tgz
version: 0.2.5-alpha.241
- apiVersion: v2
appVersion: 0.2.5-alpha.224
created: "2024-06-18T07:58:23.872622486Z"
description: A Helm chart to deploy alertmanager-discord to Kubernetes
digest: 06bbdc0cf20a19fe9c01916fca66653d839b63345c87ec9f7ce804e95ff9f342
name: alertmanager-discord
type: application
urls:
- alertmanager-discord-0.2.5-alpha.224.tgz
version: 0.2.5-alpha.224
- apiVersion: v2
appVersion: 0.2.4
created: "2024-06-18T07:58:23.872077404Z"
description: A Helm chart to deploy alertmanager-discord to Kubernetes
digest: 3808bcd0b3497a706530832651609cea6790ca90c857d240b612dc1a5fdf4dbd
name: alertmanager-discord
type: application
urls:
- alertmanager-discord-0.2.4.tgz
version: 0.2.4
- apiVersion: v2
appVersion: 0.2.4-alpha.204
created: "2024-06-18T07:58:23.87153428Z"
description: A Helm chart to deploy alertmanager-discord to Kubernetes
digest: ec677263b8ec6e57c59141b109332a2658147b77205778f1616f030e84a7b5bd
name: alertmanager-discord
type: application
urls:
- alertmanager-discord-0.2.4-alpha.204.tgz
version: 0.2.4-alpha.204
- apiVersion: v2
appVersion: 0.2.4-alpha.195
created: "2024-06-18T07:58:23.871076127Z"
description: A Helm chart to deploy alertmanager-discord to Kubernetes
digest: bdb002e7a2c01319edfe799b0cb451d8b6b4b302feb7e2fcabc8af7bf60ae22a
name: alertmanager-discord
type: application
urls:
- alertmanager-discord-0.2.4-alpha.195.tgz
version: 0.2.4-alpha.195
- apiVersion: v2
appVersion: 0.2.4-alpha.184
created: "2024-06-18T07:58:23.870621323Z"
description: A Helm chart to deploy alertmanager-discord to Kubernetes
digest: e70856450013f1b20982852d7e4bfed64640e65ea54cfd6f51d09f05f55c68e3
name: alertmanager-discord
type: application
urls:
- alertmanager-discord-0.2.4-alpha.184.tgz
version: 0.2.4-alpha.184
- apiVersion: v2
appVersion: 0.2.3
created: "2024-06-18T07:58:23.870100053Z"
description: A Helm chart to deploy alertmanager-discord to Kubernetes
digest: e9f0615fff3299f25a364d81cbbbde1d071ed02a3e106a80529c54a7eff78486
name: alertmanager-discord
type: application
urls:
- alertmanager-discord-0.2.3.tgz
version: 0.2.3
- apiVersion: v2
appVersion: 0.2.3-alpha.162
created: "2024-06-18T07:58:23.869572322Z"
description: A Helm chart to deploy alertmanager-discord to Kubernetes
digest: 05d1b1bb438c53cfef25ab455f0b8936a61f367f44802926c7c906a34d77832f
name: alertmanager-discord
type: application
urls:
- alertmanager-discord-0.2.3-alpha.162.tgz
version: 0.2.3-alpha.162
generated: "2024-06-18T07:58:23.868856027Z"
+58
View File
@@ -0,0 +1,58 @@
package test
import (
// "bytes"
"io"
"net/http"
)
type MockClientRequest struct {
Url string
ContentType string
Body io.Reader
}
type MockClientRecorder struct {
Requests []MockClientRequest
}
type RoundTripFunc func(req *http.Request) *http.Response
func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req), nil
}
func NewMockClient(fn RoundTripFunc) *http.Client {
return &http.Client{
Transport: RoundTripFunc(fn),
}
}
func (mc *MockClientRecorder) NewMockClientWithResponse(statusCode int) *http.Client {
return NewMockClient(func(req *http.Request) *http.Response {
mc.Requests = append(mc.Requests, MockClientRequest{
Url: req.URL.String(),
ContentType: req.Header.Get("content-type"),
Body: req.Body,
})
return &http.Response{
StatusCode: statusCode,
// Body: io.NopCloser(bytes.NewBufferString(responseBody)),
}
})
}
// intended to cause errors in the client
// to be used for tests of error handling
func (mc *MockClientRecorder) NewMockClientReturnsNil() *http.Client {
return NewMockClient(func(req *http.Request) *http.Response {
mc.Requests = append(mc.Requests, MockClientRequest{
Url: req.URL.String(),
ContentType: req.Header.Get("content-type"),
Body: req.Body,
})
return &http.Response{}
})
}
+3
View File
@@ -0,0 +1,3 @@
discord_webhook_url: https://discordapp.com/api/webhooks/123456789123456789/abc
listen_address: "127.0.0.1:7073"
max_backoff_time_seconds: 1