grafana/pkg/services/ngalert/notifier/channels/util.go

257 lines
7.2 KiB
Go

package channels
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"time"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/common/model"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/components/simplejson"
)
const (
FooterIconURL = "https://grafana.com/assets/img/fav32.png"
ColorAlertFiring = "#D63232"
ColorAlertResolved = "#36a64f"
// ImageStoreTimeout should be used by all callers for calles to `Images`
ImageStoreTimeout time.Duration = 500 * time.Millisecond
)
var (
// Provides current time. Can be overwritten in tests.
timeNow = time.Now
// ErrImagesDone is used to stop iteration of subsequent images. It should be
// returned from forEachFunc when either the intended image has been found or
// the maximum number of images has been iterated.
ErrImagesDone = errors.New("images done")
ErrImagesUnavailable = errors.New("alert screenshots are unavailable")
)
// For each alert, attempts to load the models.Image for an image token
// associated with the alert, then calls forEachFunc with the index of the
// alert and the retrieved image struct. If there is no image token, or the
// image does not exist, forEachFunc will be called with a nil value for the
// image. If forEachFunc returns an error, withStoredImages will return
// immediately. If there is a runtime error retrieving images from the image
// store, withStoredImages will attempt to continue executing, after logging
// a warning.
func withStoredImages(ctx context.Context, l log.Logger, imageStore ImageStore, forEachFunc func(index int, image *models.Image) error, alerts ...*types.Alert) error {
for i := range alerts {
err := withStoredImage(ctx, l, imageStore, forEachFunc, i, alerts...)
if err != nil {
// Stop iteration as forEachFunc has found the intended image or
// iterated the maximum number of images
if errors.Is(err, ErrImagesDone) {
return nil
}
return err
}
}
return nil
}
func withStoredImage(ctx context.Context, l log.Logger, imageStore ImageStore, imageFunc func(index int, image *models.Image) error, index int, alerts ...*types.Alert) error {
imgToken := getTokenFromAnnotations(alerts[index].Annotations)
if len(imgToken) == 0 {
err := imageFunc(index, nil)
if err != nil {
return err
}
}
timeoutCtx, cancel := context.WithTimeout(ctx, ImageStoreTimeout)
img, err := imageStore.GetImage(timeoutCtx, imgToken)
cancel()
if errors.Is(err, models.ErrImageNotFound) || errors.Is(err, ErrImagesUnavailable) {
err := imageFunc(index, nil)
if err != nil {
return err
}
} else if err != nil {
// Ignore errors. Don't log "ImageUnavailable", which means the storage doesn't exist.
l.Warn("failed to retrieve image url from store", "err", err)
}
err = imageFunc(index, img)
if err != nil {
return err
}
return nil
}
// The path argument here comes from reading internal image storage, not user
// input, so we ignore the security check here.
//nolint:gosec
func openImage(path string) (io.ReadCloser, error) {
fp := filepath.Clean(path)
_, err := os.Stat(fp)
if os.IsNotExist(err) || os.IsPermission(err) {
return nil, models.ErrImageNotFound
}
f, err := os.Open(fp)
if err != nil {
return nil, err
}
return f, nil
}
func getTokenFromAnnotations(annotations model.LabelSet) string {
if value, ok := annotations[models.ScreenshotTokenAnnotation]; ok {
return string(value)
}
return ""
}
type UnavailableImageStore struct{}
// Get returns the image with the corresponding token, or ErrImageNotFound.
func (u *UnavailableImageStore) GetImage(ctx context.Context, token string) (*models.Image, error) {
return nil, ErrImagesUnavailable
}
type receiverInitError struct {
Reason string
Err error
Cfg NotificationChannelConfig
}
func (e receiverInitError) Error() string {
name := ""
if e.Cfg.Name != "" {
name = fmt.Sprintf("%q ", e.Cfg.Name)
}
s := fmt.Sprintf("failed to validate receiver %sof type %q: %s", name, e.Cfg.Type, e.Reason)
if e.Err != nil {
return fmt.Sprintf("%s: %s", s, e.Err.Error())
}
return s
}
func (e receiverInitError) Unwrap() error { return e.Err }
func getAlertStatusColor(status model.AlertStatus) string {
if status == model.AlertFiring {
return ColorAlertFiring
}
return ColorAlertResolved
}
type NotificationChannel interface {
notify.Notifier
notify.ResolvedSender
}
type NotificationChannelConfig struct {
OrgID int64 // only used internally
UID string `json:"uid"`
Name string `json:"name"`
Type string `json:"type"`
DisableResolveMessage bool `json:"disableResolveMessage"`
Settings *simplejson.Json `json:"settings"`
SecureSettings map[string][]byte `json:"secureSettings"`
}
type httpCfg struct {
body []byte
user string
password string
}
// sendHTTPRequest sends an HTTP request.
// Stubbable by tests.
var sendHTTPRequest = func(ctx context.Context, url *url.URL, cfg httpCfg, logger log.Logger) ([]byte, error) {
var reader io.Reader
if len(cfg.body) > 0 {
reader = bytes.NewReader(cfg.body)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, url.String(), reader)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
}
if cfg.user != "" && cfg.password != "" {
request.Header.Set("Authorization", util.GetBasicAuthHeader(cfg.user, cfg.password))
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Grafana")
netTransport := &http.Transport{
TLSClientConfig: &tls.Config{
Renegotiation: tls.RenegotiateFreelyAsClient,
},
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
}
netClient := &http.Client{
Timeout: time.Second * 30,
Transport: netTransport,
}
resp, err := netClient.Do(request)
if err != nil {
return nil, err
}
defer func() {
if err := resp.Body.Close(); err != nil {
logger.Warn("failed to close response body", "err", err)
}
}()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode/100 != 2 {
logger.Warn("HTTP request failed", "url", request.URL.String(), "statusCode", resp.Status, "body",
string(respBody))
return nil, fmt.Errorf("failed to send HTTP request - status code %d", resp.StatusCode)
}
logger.Debug("sending HTTP request succeeded", "url", request.URL.String(), "statusCode", resp.Status)
return respBody, nil
}
func joinUrlPath(base, additionalPath string, logger log.Logger) string {
u, err := url.Parse(base)
if err != nil {
logger.Debug("failed to parse URL while joining URL", "url", base, "err", err.Error())
return base
}
u.Path = path.Join(u.Path, additionalPath)
return u.String()
}
// GetBoundary is used for overriding the behaviour for tests
// and set a boundary for multipart body. DO NOT set this outside tests.
var GetBoundary = func() string {
return ""
}