├── .env.sample ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ └── pr-checks.yaml ├── .gitignore ├── .golangci.yml ├── CHANGELOG.md ├── CODEOWNERS ├── CONTRIBUTING.md ├── LICENSE ├── MAINTAINERS.MD ├── Makefile ├── README.md ├── cluster ├── cluster.go └── clusterpb │ └── clusterpb.go ├── definition ├── alertmanager.go ├── alertmanager_test.go ├── alertmanager_validation.go ├── alertmanager_validation_test.go ├── compat.go ├── compat_test.go ├── merge.go └── merge_test.go ├── go.mod ├── go.sum ├── http ├── client.go ├── client_test.go ├── hmac.go ├── hmac_test.go ├── testing.go ├── tls.go ├── tls_test.go └── webhook.go ├── images ├── images.go ├── providers.go ├── providers_test.go ├── testing.go ├── util_test.go └── utils.go ├── models ├── labels.go └── receivers.go ├── notify ├── alerts.go ├── compat.go ├── compat_test.go ├── crypto.go ├── factory.go ├── factory_test.go ├── grafana_alertmanager.go ├── grafana_alertmanager_metrics.go ├── grafana_alertmanager_test.go ├── mimir_alertmanager.go ├── multiorg_alertmanager.go ├── nfstatus │ ├── integration.go │ ├── integration_test.go │ └── receiver.go ├── receivers.go ├── receivers_test.go ├── silences.go ├── silences_test.go ├── stages │ ├── wait_stage.go │ └── wait_stage_test.go ├── status.go ├── templates.go ├── templates_test.go └── testing.go ├── receivers ├── alertmanager │ ├── alertmanager.go │ ├── alertmanager_test.go │ ├── config.go │ ├── config_test.go │ └── testing.go ├── base.go ├── config_util.go ├── dinding │ ├── config.go │ ├── config_test.go │ ├── dingding.go │ ├── dingding_test.go │ └── testing.go ├── discord │ ├── config.go │ ├── config_test.go │ ├── discord.go │ ├── discord_test.go │ └── testing.go ├── email.go ├── email │ ├── config.go │ ├── config_test.go │ ├── email.go │ ├── email_test.go │ └── testing.go ├── email_sender.go ├── email_sender_test.go ├── googlechat │ ├── config.go │ ├── config_test.go │ ├── googlechat.go │ ├── googlechat_test.go │ └── testing.go ├── jira │ ├── config.go │ ├── config_test.go │ ├── jira.go │ ├── jira_test.go │ ├── testing.go │ └── types.go ├── kafka │ ├── config.go │ ├── config_test.go │ ├── kafka.go │ ├── kafka_test.go │ └── testing.go ├── line │ ├── config.go │ ├── config_test.go │ ├── line.go │ ├── line_test.go │ └── testing.go ├── mqtt │ ├── client.go │ ├── client_test.go │ ├── config.go │ ├── config_test.go │ ├── mqtt.go │ ├── mqtt_test.go │ └── testing.go ├── number.go ├── number_test.go ├── oncall │ ├── config.go │ ├── config_test.go │ ├── oncall.go │ ├── oncall_test.go │ └── testing.go ├── opsgenie │ ├── config.go │ ├── config_test.go │ ├── opsgenie.go │ ├── opsgenie_test.go │ └── testing.go ├── pagerduty │ ├── config.go │ ├── config_test.go │ ├── pagerduty.go │ ├── pagerduty_test.go │ └── testing.go ├── pushover │ ├── config.go │ ├── config_test.go │ ├── pushover.go │ ├── pushover_test.go │ └── testing.go ├── sensugo │ ├── config.go │ ├── config_test.go │ ├── sensugo.go │ ├── sensugo_test.go │ └── testing.go ├── slack │ ├── config.go │ ├── config_test.go │ ├── slack.go │ ├── slack_test.go │ └── testing.go ├── sns │ ├── config.go │ ├── config_test.go │ ├── sns.go │ ├── sns_test.go │ └── testing.go ├── teams │ ├── config.go │ ├── config_test.go │ ├── models.go │ ├── teams.go │ ├── teams_test.go │ └── testing.go ├── telegram │ ├── config.go │ ├── config_test.go │ ├── telegram.go │ ├── telegram_test.go │ └── testing.go ├── templates │ ├── ng_alert_notification.html │ └── ng_alert_notification.txt ├── testing.go ├── testing │ └── testing.go ├── threema │ ├── config.go │ ├── config_test.go │ ├── testing.go │ ├── threema.go │ └── threema_test.go ├── util.go ├── util_test.go ├── victorops │ ├── config.go │ ├── config_test.go │ ├── testing.go │ ├── victorops.go │ └── victorops_test.go ├── webex │ ├── config.go │ ├── config_test.go │ ├── testing.go │ ├── webex.go │ └── webex_test.go ├── webhook.go ├── webhook │ ├── config.go │ ├── config_test.go │ ├── fixtures │ │ ├── ca.pem │ │ ├── client.key │ │ └── client.pem │ ├── testing.go │ ├── webhook.go │ └── webhook_test.go └── wecom │ ├── config.go │ ├── config_test.go │ ├── testing.go │ ├── wecom.go │ └── wecom_test.go └── templates ├── default_template.go ├── default_template_test.go ├── factory.go ├── factory_test.go ├── funcs.go ├── gomplate ├── collections.go ├── data.go ├── evalargs.go ├── funcs.go ├── template.go └── time.go ├── mimir └── template.go ├── mimir_template_test.go ├── template_data.go ├── util.go └── util_test.go /.env.sample: -------------------------------------------------------------------------------- 1 | DRONE_SERVER=our_internal_drone_server 2 | DRONE_TOKEN=your_personal_token 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | day: monday 8 | -------------------------------------------------------------------------------- /.github/workflows/pr-checks.yaml: -------------------------------------------------------------------------------- 1 | name: Validate Pull Request 2 | 3 | on: 4 | pull_request: 5 | 6 | concurrency: 7 | group: "pr-${{ github.event.pull_request.number }}" 8 | cancel-in-progress: true 9 | 10 | permissions: {} 11 | 12 | jobs: 13 | validate: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | with: 21 | persist-credentials: false 22 | 23 | - name: Set up Go 24 | uses: actions/setup-go@v5 25 | with: 26 | "go-version-file": "go.mod" 27 | 28 | - name: Verify dependencies 29 | run: make mod-check 30 | 31 | - name: Run linting 32 | run: make lint 33 | 34 | - name: Run tests 35 | run: make test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.exe 2 | *.exe~ 3 | *.dll 4 | *.so 5 | *.dylib 6 | *.test 7 | *.out 8 | /.tools/ 9 | vendor 10 | .DS_Store 11 | 12 | .idea 13 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | output: 2 | formats: line-number 3 | 4 | linters: 5 | enable: 6 | - goimports 7 | - gofmt 8 | - misspell 9 | - errorlint 10 | - revive 11 | 12 | linters-settings: 13 | errcheck: 14 | exclude-functions: 15 | - (github.com/go-kit/kit/log.Logger).Log 16 | - (github.com/go-kit/log.Logger).Log 17 | 18 | errorlint: 19 | # Check for plain error comparisons. 20 | comparison: true 21 | 22 | # Do not check for plain type assertions and type switches. 23 | asserts: false 24 | 25 | errorf: false 26 | 27 | run: 28 | timeout: 5m 29 | 30 | issues: 31 | exclude-dirs: 32 | - alerting/channels # TODO(gotjosh): remove this once we get to aligning the notifiers. 33 | 34 | # List of build tags, all linters use it. 35 | build-tags: 36 | - netgo 37 | - requires_docker 38 | - requires_libpcap 39 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Alerting 4 | 5 | - 6 | 7 | ## Scope Glossary 8 | 9 | ### `[ADMIN]` 10 | The ADMIN scope denotes a change that affect the structure and layout of this repository. This includes updates to the following: 11 | 12 | - CODEOWNERS 13 | - README 14 | - DotFiles (.gitignore, .git-attributes, etc) 15 | 16 | Anything that a developer working on this repo should be aware of from a standards and practice perspective. 17 | 18 | ### `[BUGFIX]` 19 | 20 | The BUGFIX scope denotes a change that fixes an issue with the project in question. A BUGFIX should align the behaviour of the service with the current expected behaviour of the service. If a BUGFIX introduces new unexpected behaviour to ameliorate the issue, a corresponding FEATURE or ENHANCEMENT scope should also be added to the changelog. 21 | 22 | ### `[CHANGE]` 23 | 24 | The CHANGE scope denotes a change that changes the expected behavior of the project while not adding new functionality or fixing an underling issue. This commonly occurs when renaming things to make them more consistent or to accommodate updated versions of vendored dependencies. 25 | 26 | ### `[FEATURE]` 27 | 28 | The FEATURE scope denotes a change that adds new functionality to the project/service. 29 | 30 | ### `[ENHANCEMENT]` 31 | 32 | The ENHANCEMENT scope denotes a change that improves upon the current functionality of the project/service. Generally, an enhancement is something that improves upon something that is already present. Either by making it simpler, more powerful, or more performant. For Example: 33 | 34 | An optimization on a particular process in a service that makes it more performant 35 | Simpler syntax for setting a configuration value, like allowing 1m instead of 60 for a duration setting. 36 | 37 | ## Order 38 | 39 | Scopes must have an order to ensure consistency and ease of search, this helps us identify which section do we need to look for what. The order must be: 40 | 41 | 1. `[CHANGE]` 42 | 2. `[FEATURE]` 43 | 3. `[BUGFIX]` 44 | 4. `[ENHANCEMENT]` 45 | 5. `[ADMIN]` 46 | 47 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @grafana/alerting-backend 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Welcome! We're excited that you're interested in contributing. Below are some 4 | basic guidelines. 5 | 6 | ## Workflow 7 | 8 | Alerting follows a standard GitHub pull request workflow. If you're unfamiliar 9 | with this workflow, read the very helpful [Understanding the GitHub 10 | flow][github-flow] guide from GitHub. 11 | 12 | [github-flow]: https://guides.github.com/introduction/flow/ 13 | 14 | You are welcome to create draft PRs at any stage of readiness - this can be 15 | helpful to ask for assistance or to develop an idea. But before a piece of work 16 | is finished it should: 17 | 18 | * Be organised into one or more commits, each of which has a commit message 19 | that describes all changes made in that commit ('why' more than 'what' - we 20 | can read the diffs to see the code that changed). 21 | 22 | * Each commit should build towards the whole - don't leave in back-tracks and 23 | mistakes that you later corrected. 24 | 25 | * Have unit tests for new functionality or tests that would have caught the bug 26 | being fixed. 27 | 28 | * Include a CHANGELOG message if users of Alerting need to hear about what you 29 | did. 30 | 31 | To run the unit tests suite: 32 | 33 | ```bash 34 | make test 35 | ``` 36 | 37 | ### Dependency management 38 | 39 | We use [Go modules] to manage dependencies on external packages. This requires 40 | a working Go environment with version 1.16 or greater and git installed. 41 | 42 | [Go modules]: https://golang.org/cmd/go/#hdr-Modules__module_versions__and_more 43 | 44 | To add or update a new dependency, use the `go get` command: 45 | 46 | ```bash 47 | # Pick the latest tagged release. 48 | go get example.com/some/module/pkg 49 | 50 | # Pick a specific version. 51 | go get example.com/some/module/pkg@vX.Y.Z 52 | ``` 53 | 54 | Tidy up the `go.mod` and `go.sum` files: 55 | 56 | ```bash 57 | go mod tidy 58 | git add go.mod go.sum 59 | git commit 60 | ``` 61 | 62 | You have to commit the changes to `go.mod` and `go.sum` before submitting the 63 | pull request. 64 | 65 | alerting uses the `goimports` tool (`go get golang.org/x/tools/cmd/goimports` to 66 | install) to format the Go files, and sort imports. We use goimports with 67 | `-local github.com/grafana/alerting` parameter, to put Alerting internal imports into 68 | a separate group. We try to keep imports sorted into three groups: 69 | imports from standard library, imports of 3rd party packages and internal 70 | imports. Goimports will fix the order, but will keep existing newlines 71 | between imports in the groups. We try to avoid extra newlines like that. -------------------------------------------------------------------------------- /MAINTAINERS.MD: -------------------------------------------------------------------------------- 1 | The following are the main/default maintainers: 2 | 3 | - George Robinson - [@grobinson-grafana](https://github.com/grobinson-grafana) ([Grafana Labs](https://grafana.com)) 4 | - Jean-Philippe Quéméner - [@JohnnyQQQQ](https://github.com/JohnnyQQQQ) ([Grafana Labs](https://grafana.com)) 5 | - Yuriy Tseretyan - [@yuri-tceretian](https://github.com/yuri-tceretian) ([Grafana Labs](https://grafana.com)) 6 | - Josue Abreu - [@gotjosh](https://github.com/gotjosh) ([Grafana Labs](https://grafana.com)) 7 | - Santiago Hernández - [@santihernandezc](https://github.com/santihernandezc) ([Grafana Labs](https://grafana.com)) 8 | - Alexander Weaver - [@alexweav](https://github.com/alexweav) ([Grafana Labs](https://grafana.com)) 9 | - Matthew Jacobson - [@JacobsonMT](https://github.com/JacobsonMT) ([Grafana Labs](https://grafana.com)) 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Put tools at the root of the folder. 2 | PATH := $(CURDIR)/.tools/bin:$(PATH) 3 | 4 | .PHONY: clean 5 | clean: 6 | @# go mod makes the modules read-only, so before deletion we need to make them deleteable 7 | @chmod -R u+rwX .tools 2> /dev/null || true 8 | rm -rf .tools/ 9 | 10 | .PHONY: test 11 | test: 12 | go test -tags netgo -timeout 30m -race -count 1 ./... 13 | 14 | .PHONY: lint 15 | lint: .tools/bin/misspell .tools/bin/faillint .tools/bin/golangci-lint 16 | misspell -error README.md CONTRIBUTING.md LICENSE 17 | 18 | # Configured via .golangci.yml. 19 | golangci-lint run 20 | 21 | .PHONY: mod-check 22 | mod-check: 23 | GO111MODULE=on go mod download 24 | GO111MODULE=on go mod verify 25 | GO111MODULE=on go mod tidy 26 | @git diff --exit-code -- go.sum go.mod 27 | 28 | .PHONY: drone 29 | drone: .drone/drone.yml 30 | 31 | # Drone. 32 | .drone/drone.yml: .drone/drone.jsonnet 33 | drone jsonnet --source $< --target $@.tmp --stream --format=false 34 | drone sign --save grafana/alerting $@.tmp 35 | drone lint --trusted $@.tmp 36 | mv $@.tmp $@ 37 | 38 | # Tools needed to run linting. 39 | .tools: 40 | mkdir -p .tools/ 41 | 42 | .tools/bin/misspell: .tools 43 | GOPATH=$(CURDIR)/.tools go install github.com/client9/misspell/cmd/misspell@v0.3.4 44 | 45 | .tools/bin/faillint: .tools 46 | GOPATH=$(CURDIR)/.tools go install github.com/fatih/faillint@v1.10.0 47 | 48 | .tools/bin/golangci-lint: .tools 49 | GOPATH=$(CURDIR)/.tools go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.61.0 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://drone.grafana.net/api/badges/grafana/alerting/status.svg?ref=refs/heads/main)](https://drone.grafana.net/grafana/alerting) 2 | 3 | # Alerting 4 | 5 | This library contains utilities that are useful for building Alerting systems. 6 | 7 | ## Current state 8 | 9 | This library is still in development. 10 | 11 | ## Contributing 12 | 13 | If you're interested in contributing to this project: 14 | 15 | - Start by reading the [Contributing guide](/CONTRIBUTING.md). 16 | 17 | ## License 18 | 19 | [AGPL-3.0](https://github.com/grafana/alerting/blob/main/LICENSE) -------------------------------------------------------------------------------- /cluster/cluster.go: -------------------------------------------------------------------------------- 1 | package cluster 2 | 3 | import ( 4 | "github.com/prometheus/alertmanager/cluster" 5 | ) 6 | 7 | const ( 8 | DefaultGossipInterval = cluster.DefaultGossipInterval 9 | DefaultPushPullInterval = cluster.DefaultPushPullInterval 10 | DefaultProbeInterval = cluster.DefaultProbeInterval 11 | DefaultProbeTimeout = cluster.DefaultProbeTimeout 12 | DefaultReconnectInterval = cluster.DefaultReconnectInterval 13 | DefaultReconnectTimeout = cluster.DefaultReconnectTimeout 14 | DefaultTCPTimeout = cluster.DefaultTCPTimeout 15 | ) 16 | 17 | var ( 18 | Create = cluster.Create 19 | ) 20 | 21 | type ClusterChannel = cluster.ClusterChannel //nolint:revive 22 | type Peer = cluster.Peer 23 | type State = cluster.State 24 | -------------------------------------------------------------------------------- /cluster/clusterpb/clusterpb.go: -------------------------------------------------------------------------------- 1 | package clusterpb 2 | 3 | import ( 4 | "github.com/prometheus/alertmanager/cluster/clusterpb" 5 | ) 6 | 7 | type FullState = clusterpb.FullState 8 | type Part = clusterpb.Part 9 | -------------------------------------------------------------------------------- /http/hmac.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "bytes" 5 | "crypto/hmac" 6 | "crypto/sha256" 7 | "encoding/hex" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "net/http" 12 | "strconv" 13 | 14 | "github.com/benbjohnson/clock" 15 | ) 16 | 17 | const ( 18 | // defaultHeaderName is the default HTTP header used for the HMAC signature 19 | defaultHeaderName = "X-Grafana-Alerting-Signature" 20 | // timestampSeparator is used to separate the timestamp from the request body in the HMAC calculation 21 | timestampSeparator = ":" 22 | ) 23 | 24 | // HMACRoundTripper is an HTTP transport that signs outgoing requests using HMAC SHA256. 25 | // It can optionally include a timestamp in the signature calculation (if timestampHeader is not empty) 26 | // and supports custom header names for both the signature and timestamp values. 27 | type HMACRoundTripper struct { 28 | wrapped http.RoundTripper 29 | clk clock.Clock 30 | secret string 31 | header string 32 | timestampHeader string 33 | } 34 | 35 | // NewHMACRoundTripper creates a new HMACRoundTripper that wraps the provided RoundTripper. 36 | // It signs requests using the provided secret key and places the signature in the specified header. 37 | // If header is empty, it defaults to "X-Grafana-Alert-Signature". 38 | // If timestampHeader is non-empty, the current timestamp will be included in the signature 39 | // calculation and set in the specified header. 40 | func NewHMACRoundTripper(wrapped http.RoundTripper, clk clock.Clock, secret, header, timestampHeader string) (*HMACRoundTripper, error) { 41 | if secret == "" { 42 | return nil, errors.New("secret must be provided") 43 | } 44 | if header == "" { 45 | header = defaultHeaderName 46 | } 47 | return &HMACRoundTripper{ 48 | wrapped: wrapped, 49 | clk: clk, 50 | secret: secret, 51 | header: header, 52 | timestampHeader: timestampHeader, 53 | }, nil 54 | } 55 | 56 | func (rt *HMACRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { 57 | if err := rt.sign(req); err != nil { 58 | return nil, fmt.Errorf("failed to sign request: %w", err) 59 | } 60 | return rt.wrapped.RoundTrip(req) 61 | } 62 | 63 | // sign computes the HMAC SHA256 signature for the request body. 64 | // If a timestamp header is configured, it includes the current timestamp in the signature. 65 | // The computed signature is then set in the request header, and the request body is restored. 66 | func (rt *HMACRoundTripper) sign(req *http.Request) error { 67 | if req.Body == nil { 68 | return nil 69 | } 70 | 71 | body, err := io.ReadAll(req.Body) 72 | if err != nil { 73 | return fmt.Errorf("failed to read request body: %w", err) 74 | } 75 | req.Body.Close() 76 | req.Body = io.NopCloser(bytes.NewReader(body)) 77 | 78 | hash := hmac.New(sha256.New, []byte(rt.secret)) 79 | 80 | if rt.timestampHeader != "" { 81 | timestamp := strconv.FormatInt(rt.clk.Now().Unix(), 10) 82 | req.Header.Set(rt.timestampHeader, timestamp) 83 | hash.Write([]byte(timestamp)) 84 | hash.Write([]byte(timestampSeparator)) 85 | } 86 | 87 | hash.Write(body) 88 | signature := hex.EncodeToString(hash.Sum(nil)) 89 | req.Header.Set(rt.header, signature) 90 | 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /http/hmac_test.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "bytes" 5 | "crypto/hmac" 6 | "crypto/sha256" 7 | "encoding/hex" 8 | "io" 9 | "net/http" 10 | "strconv" 11 | "testing" 12 | 13 | "github.com/benbjohnson/clock" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | func computeHMAC(t *testing.T, secret, body string, clk clock.Clock) string { 18 | t.Helper() 19 | 20 | hash := hmac.New(sha256.New, []byte(secret)) 21 | 22 | if clk != nil { 23 | ts := strconv.FormatInt(clk.Now().Unix(), 10) 24 | hash.Write([]byte(ts)) 25 | hash.Write([]byte(":")) 26 | } 27 | hash.Write([]byte(body)) 28 | 29 | return hex.EncodeToString(hash.Sum(nil)) 30 | } 31 | 32 | func TestHMACRoundTripper(t *testing.T) { 33 | mockClock := clock.NewMock() 34 | 35 | testCases := []struct { 36 | name string 37 | secret string 38 | header string 39 | timestampHeader string 40 | body string 41 | expectErr bool 42 | }{ 43 | { 44 | name: "Valid signing without timestamp", 45 | secret: "secret", 46 | header: "X-Signature", 47 | timestampHeader: "", 48 | body: "test message", 49 | }, 50 | { 51 | name: "Valid signing with timestamp", 52 | secret: "secret", 53 | header: "X-Signature", 54 | timestampHeader: "X-Timestamp", 55 | body: "test message", 56 | }, 57 | { 58 | name: "Empty secret", 59 | secret: "", 60 | header: "X-Signature", 61 | timestampHeader: "", 62 | body: "test message", 63 | expectErr: true, 64 | }, 65 | { 66 | name: "Empty header uses default", 67 | secret: "secret", 68 | header: "", 69 | timestampHeader: "", 70 | body: "test message", 71 | }, 72 | { 73 | name: "Empty body without timestamp", 74 | secret: "secret", 75 | header: "X-Signature", 76 | timestampHeader: "", 77 | body: "", 78 | }, 79 | { 80 | name: "Empty body with timestamp", 81 | secret: "secret", 82 | header: "X-Signature", 83 | timestampHeader: "X-Timestamp", 84 | body: "", 85 | }, 86 | } 87 | 88 | for _, tc := range testCases { 89 | t.Run(tc.name, func(t *testing.T) { 90 | rt, err := NewHMACRoundTripper(http.DefaultTransport, mockClock, tc.secret, tc.header, tc.timestampHeader) 91 | 92 | if tc.expectErr { 93 | require.Error(t, err) 94 | return 95 | } 96 | require.NoError(t, err) 97 | 98 | // Create request with body 99 | body := bytes.NewReader([]byte(tc.body)) 100 | req, err := http.NewRequest(http.MethodPost, "http://example.com", body) 101 | require.NoError(t, err) 102 | 103 | err = rt.sign(req) 104 | require.NoError(t, err) 105 | 106 | // Verify the signature and the timestamp 107 | headerName := tc.header 108 | if headerName == "" { 109 | headerName = defaultHeaderName 110 | } 111 | 112 | var clkForSigning clock.Clock 113 | if tc.timestampHeader != "" { 114 | clkForSigning = mockClock 115 | } 116 | expectedHash := computeHMAC(t, tc.secret, tc.body, clkForSigning) 117 | require.Equal(t, expectedHash, req.Header.Get(headerName)) 118 | 119 | if tc.timestampHeader != "" { 120 | ts := strconv.FormatInt(mockClock.Now().Unix(), 10) 121 | require.Equal(t, ts, req.Header.Get(tc.timestampHeader)) 122 | } 123 | 124 | // Verify that the body can still be read 125 | if req.Body != nil { 126 | bodyBytes, err := io.ReadAll(req.Body) 127 | require.NoError(t, err) 128 | require.Equal(t, tc.body, string(bodyBytes)) 129 | } 130 | }) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /http/testing.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | const TestCACert = `-----BEGIN CERTIFICATE----- 4 | MIGrMF+gAwIBAgIBATAFBgMrZXAwADAeFw0yNDExMTYxMDI4MzNaFw0yNTExMTYx 5 | MDI4MzNaMAAwKjAFBgMrZXADIQCf30GvRnHbs9gukA3DLXDK6W5JVgYw6mERU/60 6 | 2M8+rjAFBgMrZXADQQCGmeaRp/AcjeqmJrF5Yh4d7aqsMSqVZvfGNDc0ppXyUgS3 7 | WMQ1+3T+/pkhU612HR0vFd3vyFhmB4yqFoNV8RML 8 | -----END CERTIFICATE-----` 9 | const TestCertPem = `-----BEGIN CERTIFICATE----- 10 | MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw 11 | DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow 12 | EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d 13 | 7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B 14 | 5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr 15 | BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1 16 | NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l 17 | Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc 18 | 6MF9+Yw1Yy0t 19 | -----END CERTIFICATE-----` 20 | const TestKeyPem = `-----BEGIN EC PRIVATE KEY----- 21 | MHcCAQEEIIrYSSNQFaA2Hwf1duRSxKtLYX5CB04fSeQ6tF1aY/PuoAoGCCqGSM49 22 | AwEHoUQDQgAEPR3tU2Fta9ktY+6P9G0cWO+0kETA6SFs38GecTyudlHz6xvCdz8q 23 | EKTcWGekdmdDPsHloRNtsiCa697B2O9IFA== 24 | -----END EC PRIVATE KEY-----` 25 | -------------------------------------------------------------------------------- /http/tls.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "net" 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | // NewTLSClient creates a new HTTP client with the provided TLS configuration or with default settings. 12 | func NewTLSClient(tlsConfig *tls.Config, dialContextfunc func(context.Context, string, string) (net.Conn, error)) *http.Client { 13 | if tlsConfig == nil { 14 | tlsConfig = &tls.Config{ 15 | Renegotiation: tls.RenegotiateFreelyAsClient, 16 | } 17 | } 18 | 19 | if dialContextfunc == nil { 20 | dialContextfunc = (&net.Dialer{ 21 | Timeout: 30 * time.Second, 22 | }).DialContext 23 | } 24 | 25 | return &http.Client{ 26 | Timeout: time.Second * 30, 27 | Transport: &http.Transport{ 28 | TLSClientConfig: tlsConfig, 29 | Proxy: http.ProxyFromEnvironment, 30 | DialContext: dialContextfunc, 31 | TLSHandshakeTimeout: 5 * time.Second, 32 | }, 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /http/tls_test.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "crypto/tls" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func Test_NewTLSClient(t *testing.T) { 12 | tc := []struct { 13 | name string 14 | cfg *tls.Config 15 | expCfg *tls.Config 16 | }{ 17 | { 18 | name: "empty TLSConfig", 19 | expCfg: &tls.Config{Renegotiation: tls.RenegotiateFreelyAsClient}, 20 | }, 21 | { 22 | name: "valid TLSConfig", 23 | cfg: &tls.Config{InsecureSkipVerify: true}, 24 | expCfg: &tls.Config{InsecureSkipVerify: true}, 25 | }, 26 | } 27 | 28 | for _, tt := range tc { 29 | t.Run(tt.name, func(t *testing.T) { 30 | c := NewTLSClient(tt.cfg, nil) 31 | require.Equal(t, tt.expCfg, c.Transport.(*http.Transport).TLSClientConfig) 32 | }) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /http/webhook.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | 10 | "github.com/go-kit/log" 11 | 12 | "github.com/grafana/alerting/receivers" 13 | ) 14 | 15 | type ForkedSender struct { 16 | cli *Client 17 | } 18 | 19 | func NewForkedSender(cli *Client) *ForkedSender { 20 | return &ForkedSender{cli: cli} 21 | } 22 | 23 | func (f ForkedSender) SendWebhook(ctx context.Context, l log.Logger, cmd *receivers.SendWebhookSettings) error { 24 | if cmd.HTTPMethod != "GET" { 25 | return f.cli.SendWebhook(ctx, l, cmd) 26 | } 27 | 28 | request, err := http.NewRequestWithContext(ctx, cmd.HTTPMethod, cmd.URL, nil) 29 | if err != nil { 30 | return err 31 | } 32 | _, err = url.Parse(cmd.URL) 33 | if err != nil { 34 | // Should not be possible - NewRequestWithContext should also err if the URL is bad. 35 | return err 36 | } 37 | 38 | request.Header.Set("User-Agent", "Grafana") 39 | 40 | if cmd.User != "" && cmd.Password != "" { 41 | request.SetBasicAuth(cmd.User, cmd.Password) 42 | } 43 | 44 | for k, v := range cmd.HTTPHeader { 45 | request.Header.Set(k, v) 46 | } 47 | 48 | resp, err := NewTLSClient(cmd.TLSConfig, f.cli.cfg.dialer.DialContext).Do(request) 49 | if err != nil { 50 | return redactURL(err) 51 | } 52 | defer func() { 53 | _ = resp.Body.Close() 54 | }() 55 | 56 | body, err := io.ReadAll(resp.Body) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | if cmd.Validation != nil { 62 | err := cmd.Validation(body, resp.StatusCode) 63 | if err != nil { 64 | return fmt.Errorf("webhook failed validation: %w", err) 65 | } 66 | } 67 | 68 | if resp.StatusCode/100 == 2 { 69 | return nil 70 | } 71 | 72 | return fmt.Errorf("webhook response status %v", resp.Status) 73 | } 74 | -------------------------------------------------------------------------------- /images/images.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/prometheus/alertmanager/types" 8 | ) 9 | 10 | var ( 11 | ErrImageNotFound = errors.New("image not found") 12 | 13 | // ErrImagesDone is used to stop iteration of subsequent images. It should be 14 | // returned from forEachFunc when either the intended image has been found or 15 | // the maximum number of images has been iterated. 16 | ErrImagesDone = errors.New("images done") 17 | 18 | ErrImagesUnavailable = errors.New("alert screenshots are unavailable") 19 | ) 20 | 21 | type ImageContent struct { 22 | // Name is the unique identifier for the image. Usually this will be an image filename, but is not required to be. 23 | Name string 24 | // Content is the raw image data. 25 | Content []byte 26 | } 27 | 28 | type Image struct { 29 | // ID is the unique identifier for the image. 30 | ID string 31 | // URL is the public URL of the image. This URL should not be treated as a trusted source and should not be 32 | // downloaded directly. RawData should be used to retrieve the image data. 33 | URL string 34 | // RawData returns the raw image data. Depending on the provider, this may be a file read, a network request, or 35 | // unsupported. It's the responsibility of the Provider to ensure that the data is safe to read. 36 | RawData func(ctx context.Context) (ImageContent, error) 37 | } 38 | 39 | func (i Image) HasURL() bool { 40 | return i.URL != "" 41 | } 42 | 43 | type Provider interface { 44 | // GetImage takes an alert and returns its associated image. 45 | GetImage(ctx context.Context, alert types.Alert) (*Image, error) 46 | } 47 | -------------------------------------------------------------------------------- /images/providers.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/go-kit/log" 9 | "github.com/go-kit/log/level" 10 | "github.com/prometheus/alertmanager/types" 11 | 12 | "github.com/grafana/alerting/models" 13 | ) 14 | 15 | // ErrImageUploadNotSupported is returned when image uploading is not supported. 16 | var ErrImageUploadNotSupported = errors.New("image upload is not supported") 17 | 18 | type UnavailableProvider struct{} 19 | 20 | var _ Provider = (*UnavailableProvider)(nil) 21 | 22 | func (u *UnavailableProvider) GetImage(_ context.Context, _ types.Alert) (*Image, error) { 23 | return nil, ErrImagesUnavailable 24 | } 25 | 26 | // URLProvider is a provider that stores a direct reference to an image's public URL in an alert's annotations. 27 | // The URL is not validated against a database record, so retrieving raw image data is blocked in an attempt 28 | // to prevent malicious access to untrusted URLs. 29 | type URLProvider struct{} 30 | 31 | var _ Provider = (*URLProvider)(nil) 32 | 33 | // GetImage returns the image associated with a given alert. 34 | // The URL should be treated as untrusted and notifiers should pass the URL directly without attempting to download 35 | // the image data. 36 | func (u *URLProvider) GetImage(_ context.Context, alert types.Alert) (*Image, error) { 37 | url := GetImageURL(alert) 38 | if url == "" { 39 | return nil, nil 40 | } 41 | 42 | return &Image{ 43 | ID: url, 44 | URL: url, 45 | RawData: func(_ context.Context) (ImageContent, error) { 46 | // Raw images are not available for URLs provided directly by annotations as the image data is non-local. 47 | // While it might be possible to download the image data, it's generally not safe to do so as the URL is 48 | // not guaranteed to be trusted. 49 | return ImageContent{}, fmt.Errorf("%w: URLProvider does not support raw image data", ErrImageUploadNotSupported) 50 | }, 51 | }, nil 52 | } 53 | 54 | type TokenStore interface { 55 | GetImage(ctx context.Context, token string) (*Image, error) 56 | } 57 | 58 | // TokenProvider implements the ImageProvider interface, retrieving images from a store using tokens. 59 | // Image data should be considered trusted as the stored image URL and content are not user-modifiable. 60 | type TokenProvider struct { 61 | store TokenStore 62 | logger log.Logger 63 | } 64 | 65 | var _ Provider = (*TokenProvider)(nil) 66 | 67 | func NewTokenProvider(store TokenStore, logger log.Logger) Provider { 68 | return &TokenProvider{ 69 | store: store, 70 | logger: logger, 71 | } 72 | } 73 | 74 | func (i TokenProvider) GetImage(ctx context.Context, alert types.Alert) (*Image, error) { 75 | token := GetImageToken(alert) 76 | if token == "" { 77 | return nil, nil 78 | } 79 | 80 | // Assume the uri is a token because we used to store tokens as plain strings. 81 | level.Debug(i.logger).Log("msg", "received an image token in annotations", "token", token) 82 | image, err := i.store.GetImage(ctx, token) 83 | if err != nil { 84 | if errors.Is(err, ErrImageNotFound) { 85 | level.Info(i.logger).Log("msg", "image not found in database", "token", token) 86 | return nil, nil 87 | } 88 | return nil, err 89 | } 90 | 91 | return image, nil 92 | } 93 | 94 | // GetImageToken is a helper function to retrieve the image token from the alert annotations. 95 | func GetImageToken(alert types.Alert) string { 96 | return string(alert.Annotations[models.ImageTokenAnnotation]) 97 | } 98 | 99 | // GetImageURL is a helper function to retrieve the image url from the alert annotations. 100 | func GetImageURL(alert types.Alert) string { 101 | return string(alert.Annotations[models.ImageURLAnnotation]) 102 | } 103 | -------------------------------------------------------------------------------- /images/providers_test.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/go-kit/log" 8 | "github.com/stretchr/testify/assert" 9 | 10 | "github.com/prometheus/alertmanager/types" 11 | ) 12 | 13 | func TestUnavailableProvider_GetImage(t *testing.T) { 14 | tests := []struct { 15 | name string 16 | alert types.Alert 17 | expImage *Image 18 | expError error 19 | }{ 20 | { 21 | name: "Given alert, expect error", 22 | alert: newAlertWithImageURL("https://test"), 23 | expImage: nil, 24 | expError: ErrImagesUnavailable, 25 | }, 26 | } 27 | 28 | for _, test := range tests { 29 | t.Run(test.name, func(tt *testing.T) { 30 | p := &UnavailableProvider{} 31 | img, err := p.GetImage(context.Background(), test.alert) 32 | assert.Equal(tt, test.expImage, img) 33 | assert.Equal(tt, test.expError, err) 34 | }) 35 | } 36 | } 37 | 38 | func TestURLProvider_GetImage(t *testing.T) { 39 | tests := []struct { 40 | name string 41 | alert types.Alert 42 | expImage *Image 43 | }{ 44 | { 45 | name: "Given alert without image URI, expect nil", 46 | alert: types.Alert{}, 47 | expImage: nil, 48 | }, 49 | { 50 | name: "Given alert with image URI, expect image", 51 | alert: newAlertWithImageURL("https://test"), 52 | expImage: &Image{URL: "https://test"}, 53 | }, 54 | { 55 | name: "Given alert with image token, expect nil", // Token is irrelevant for this provider. 56 | alert: newAlertWithImageToken("test-token"), 57 | expImage: nil, 58 | }, 59 | } 60 | 61 | for _, test := range tests { 62 | t.Run(test.name, func(tt *testing.T) { 63 | p := &URLProvider{} 64 | img, err := p.GetImage(context.Background(), test.alert) 65 | assert.NoError(tt, err) 66 | if test.expImage == nil { 67 | assert.Nil(tt, img) 68 | return 69 | } 70 | assert.Equal(tt, test.expImage.URL, img.URL) 71 | _, err = img.RawData(context.Background()) 72 | assert.ErrorIs(tt, err, ErrImageUploadNotSupported) 73 | }) 74 | } 75 | } 76 | 77 | func TestTokenProvider_GetImage(t *testing.T) { 78 | tests := []struct { 79 | name string 80 | storedImages map[string]*Image 81 | alert types.Alert 82 | expImage *Image 83 | }{ 84 | { 85 | name: "Given alert without image token, expect nil", 86 | storedImages: map[string]*Image{ 87 | "test-token": {URL: "https://test"}, 88 | }, 89 | alert: types.Alert{}, 90 | expImage: nil, 91 | }, 92 | { 93 | name: "Given alert with image token, expect image", 94 | storedImages: map[string]*Image{ 95 | "test-token": {URL: "https://test"}, 96 | }, 97 | alert: newAlertWithImageToken("test-token"), 98 | expImage: &Image{URL: "https://test"}, 99 | }, 100 | { 101 | name: "Given alert with invalid image token, expect nil", 102 | storedImages: map[string]*Image{ 103 | "test-token": {URL: "https://test"}, 104 | }, 105 | alert: newAlertWithImageToken("invalid"), 106 | expImage: nil, 107 | }, 108 | } 109 | 110 | for _, test := range tests { 111 | t.Run(test.name, func(tt *testing.T) { 112 | p := NewTokenProvider(FakeTokenStore{ 113 | Images: test.storedImages, 114 | }, log.NewNopLogger()) 115 | img, err := p.GetImage(context.Background(), test.alert) 116 | assert.NoError(tt, err) 117 | if test.expImage == nil { 118 | assert.Nil(tt, img) 119 | return 120 | } 121 | assert.Equal(tt, test.expImage.URL, img.URL) 122 | }) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /images/testing.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "fmt" 7 | "testing" 8 | 9 | "github.com/go-kit/log" 10 | "github.com/prometheus/alertmanager/types" 11 | "github.com/prometheus/common/model" 12 | 13 | "github.com/grafana/alerting/models" 14 | ) 15 | 16 | type FakeTokenStore struct { 17 | Images map[string]*Image 18 | } 19 | 20 | var _ TokenStore = (*FakeTokenStore)(nil) 21 | 22 | func (f FakeTokenStore) GetImage(_ context.Context, token string) (*Image, error) { 23 | return f.Images[token], nil 24 | } 25 | 26 | func NewFakeProvider(n int) Provider { 27 | return NewTokenProvider(NewFakeTokenStore(n), log.NewNopLogger()) 28 | } 29 | 30 | func NewFakeProviderWithFile(t *testing.T, n int) Provider { 31 | return NewTokenProvider(NewFakeTokenStoreWithFile(t, n), log.NewNopLogger()) 32 | } 33 | 34 | func NewFakeProviderWithStore(s TokenStore) Provider { 35 | return NewTokenProvider(s, log.NewNopLogger()) 36 | } 37 | 38 | func NewFakeTokenStoreFromImages(images map[string]*Image) *FakeTokenStore { 39 | return &FakeTokenStore{Images: images} 40 | } 41 | 42 | // NewFakeTokenStore returns an token store with N test images. 43 | // Each image has a URL, but does not have a file on disk. 44 | // They are mapped to the token test-image-%d 45 | func NewFakeTokenStore(n int) *FakeTokenStore { 46 | p := FakeTokenStore{ 47 | Images: make(map[string]*Image), 48 | } 49 | for i := 1; i <= n; i++ { 50 | token := fmt.Sprintf("test-image-%d", i) 51 | p.Images[token] = &Image{ 52 | ID: token, 53 | URL: fmt.Sprintf("https://www.example.com/test-image-%d.jpg", i), 54 | RawData: func(_ context.Context) (ImageContent, error) { 55 | return ImageContent{}, ErrImagesUnavailable 56 | }, 57 | } 58 | } 59 | return &p 60 | } 61 | 62 | // NewFakeTokenStoreWithFile returns an image provider with N test images. 63 | // Each image has a URL and raw data. 64 | // They are mapped to the token test-image-%d 65 | func NewFakeTokenStoreWithFile(t *testing.T, n int) *FakeTokenStore { 66 | p := FakeTokenStore{ 67 | Images: make(map[string]*Image), 68 | } 69 | // 1x1 transparent PNG 70 | b, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=") 71 | if err != nil { 72 | t.Fatalf("failed to decode PNG data: %s", err) 73 | } 74 | 75 | for i := 1; i <= n; i++ { 76 | token := fmt.Sprintf("test-image-%d", i) 77 | p.Images[token] = &Image{ 78 | ID: token, 79 | RawData: func(_ context.Context) (ImageContent, error) { 80 | return ImageContent{ 81 | Name: fmt.Sprintf("test-image-%d.jpg", i), 82 | Content: b, 83 | }, nil 84 | }, 85 | } 86 | } 87 | return &p 88 | } 89 | 90 | func newAlertWithImageURL(url string) types.Alert { 91 | return types.Alert{ 92 | Alert: model.Alert{ 93 | Annotations: model.LabelSet{ 94 | models.ImageURLAnnotation: model.LabelValue(url), 95 | }, 96 | }, 97 | } 98 | } 99 | 100 | func newAlertWithImageToken(token string) types.Alert { 101 | return types.Alert{ 102 | Alert: model.Alert{ 103 | Annotations: model.LabelSet{ 104 | models.ImageTokenAnnotation: model.LabelValue(token), 105 | }, 106 | }, 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /images/util_test.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/go-kit/log" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/prometheus/alertmanager/types" 12 | "github.com/prometheus/common/model" 13 | 14 | "github.com/grafana/alerting/models" 15 | ) 16 | 17 | func TestWithStoredImages(t *testing.T) { 18 | ctx := context.Background() 19 | alerts := []*types.Alert{{ 20 | Alert: model.Alert{ 21 | Annotations: model.LabelSet{ 22 | models.ImageTokenAnnotation: "test-image-1", 23 | }, 24 | }, 25 | }, { 26 | Alert: model.Alert{ 27 | Annotations: model.LabelSet{ 28 | models.ImageTokenAnnotation: "test-image-2", 29 | }, 30 | }, 31 | }} 32 | imageProvider := &TokenProvider{ 33 | store: NewFakeTokenStoreFromImages(map[string]*Image{ 34 | "test-image-1": { 35 | URL: "https://www.example.com/test-image-1.jpg", 36 | }, 37 | "test-image-2": { 38 | URL: "https://www.example.com/test-image-2.jpg", 39 | }, 40 | }, 41 | ), 42 | logger: log.NewNopLogger(), 43 | } 44 | 45 | var ( 46 | err error 47 | i int 48 | ) 49 | 50 | // should iterate all images 51 | err = WithStoredImages(ctx, log.NewNopLogger(), imageProvider, func(_ int, _ Image) error { 52 | i++ 53 | return nil 54 | }, alerts...) 55 | require.NoError(t, err) 56 | assert.Equal(t, 2, i) 57 | 58 | // should iterate just the first image 59 | i = 0 60 | err = WithStoredImages(ctx, log.NewNopLogger(), imageProvider, func(_ int, _ Image) error { 61 | i++ 62 | return ErrImagesDone 63 | }, alerts...) 64 | require.NoError(t, err) 65 | assert.Equal(t, 1, i) 66 | } 67 | -------------------------------------------------------------------------------- /images/utils.go: -------------------------------------------------------------------------------- 1 | package images 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "github.com/go-kit/log" 9 | "github.com/go-kit/log/level" 10 | "github.com/prometheus/alertmanager/types" 11 | ) 12 | 13 | const ( 14 | // ProviderTimeout should be used by all callers for calles to `Images` 15 | ProviderTimeout = 500 * time.Millisecond 16 | ) 17 | 18 | type forEachImageFunc func(index int, image Image) error 19 | 20 | // getImage returns the image for the alert or an error. It returns a nil 21 | // image if the alert does not have an image token or the image does not exist. 22 | // 23 | //nolint:revive 24 | func getImage(ctx context.Context, l log.Logger, imageProvider Provider, alert types.Alert) (*Image, error) { 25 | ctx, cancelFunc := context.WithTimeout(ctx, ProviderTimeout) 26 | defer cancelFunc() 27 | 28 | img, err := imageProvider.GetImage(ctx, alert) 29 | if errors.Is(err, ErrImageNotFound) || errors.Is(err, ErrImagesUnavailable) { 30 | return nil, nil 31 | } else if err != nil { 32 | level.Warn(l).Log("msg", "failed to get image", "err", err) 33 | return nil, err 34 | } else { 35 | return img, nil 36 | } 37 | } 38 | 39 | // WithStoredImages retrieves the image for each alert and then calls forEachFunc 40 | // with the index of the alert and the retrieved image struct. If the alert does 41 | // not have an image then forEachFunc will not be called for that alert. 42 | // If forEachFunc returns an error, WithStoredImages will return the error 43 | // and not iterate the remaining alerts. A forEachFunc can return ErrImagesDone 44 | // to stop the iteration of remaining alerts if the intended image or maximum number of 45 | // images have been found. 46 | func WithStoredImages(ctx context.Context, l log.Logger, imageProvider Provider, forEachFunc forEachImageFunc, alerts ...*types.Alert) error { 47 | if imageProvider == nil { 48 | return nil 49 | } 50 | for index, alert := range alerts { 51 | logger := log.With(l, "alert", alert.String()) 52 | img, err := getImage(ctx, logger, imageProvider, *alert) 53 | if err != nil { 54 | return err 55 | } else if img != nil { 56 | if err := forEachFunc(index, *img); err != nil { 57 | if errors.Is(err, ErrImagesDone) { 58 | return nil 59 | } 60 | level.Error(logger).Log("msg", "Failed to attach image to notification", "err", err) 61 | return err 62 | } 63 | } 64 | } 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /models/labels.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | const ( 4 | RuleUIDLabel = "__alert_rule_uid__" 5 | NamespaceUIDLabel = "__alert_rule_namespace_uid__" 6 | 7 | // Annotations are actually a set of labels, so technically this is the label name of an annotation. 8 | DashboardUIDAnnotation = "__dashboardUid__" 9 | PanelIDAnnotation = "__panelId__" 10 | OrgIDAnnotation = "__orgId__" 11 | 12 | // This isn't a hard-coded secret token, hence the nolint. 13 | //nolint:gosec 14 | ImageTokenAnnotation = "__alertImageToken__" 15 | 16 | // ImageURLAnnotation is the annotation that will contain the URL of an alert's image. 17 | ImageURLAnnotation = "__alert_image_url__" 18 | 19 | // GrafanaReservedLabelPrefix contains the prefix for Grafana reserved labels. These differ from "__