~xenrox/ntfy-alertmanager

dab77f63937576b37a85fd12065eeeb79d5986a9 — Thorben Günther 8 months ago 057b2e1
Use "log/slog" for logging
4 files changed, 75 insertions(+), 39 deletions(-)

M go.mod
M go.sum
M main.go
M silence.go
M go.mod => go.mod +2 -2
@@ 1,10 1,10 @@
module git.xenrox.net/~xenrox/ntfy-alertmanager

go 1.21
go 1.21.0

require (
	git.sr.ht/~emersion/go-scfg v0.0.0-20230601130942-e042ab15616e
	git.xenrox.net/~xenrox/go-log v0.0.0-20221012231312-9e7356c29b74
	git.xenrox.net/~xenrox/go-utils v0.0.0-20230813014007-897165f99b2e
	github.com/redis/go-redis/v9 v9.0.5
	golang.org/x/text v0.12.0
)

M go.sum => go.sum +2 -2
@@ 1,7 1,7 @@
git.sr.ht/~emersion/go-scfg v0.0.0-20230601130942-e042ab15616e h1:42zyo0ZFxHGkysM1B9EM7PnQNO0TEzPm+bw/2Zontyg=
git.sr.ht/~emersion/go-scfg v0.0.0-20230601130942-e042ab15616e/go.mod h1:ybgvEJTIx5XbaspSviB3KNa6OdPmAZqDoSud7z8fFlw=
git.xenrox.net/~xenrox/go-log v0.0.0-20221012231312-9e7356c29b74 h1:t/52xLRU4IHd2O1nkb9fcUE6K95/KdBtdQYHT31szps=
git.xenrox.net/~xenrox/go-log v0.0.0-20221012231312-9e7356c29b74/go.mod h1:d98WFDHGpxaEThKue5CfGtr9OrWgbaApprt3GH+OM4s=
git.xenrox.net/~xenrox/go-utils v0.0.0-20230813014007-897165f99b2e h1:xqkh37YE78gxnJdlLrHc1WCRx3aYE/7XG8uZ6IJWCn0=
git.xenrox.net/~xenrox/go-utils v0.0.0-20230813014007-897165f99b2e/go.mod h1:BM4sMPD0fqFB6eG1T/7rGgEUiqZsMpHvq4PGE861Sfk=
github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao=
github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w=
github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y=

M main.go => main.go +48 -24
@@ 10,6 10,7 @@ import (
	"encoding/json"
	"flag"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"os/signal"


@@ 18,7 19,7 @@ import (
	"syscall"
	"time"

	"git.xenrox.net/~xenrox/go-log"
	"git.xenrox.net/~xenrox/go-utils/logging"
	"git.xenrox.net/~xenrox/ntfy-alertmanager/cache"
	"git.xenrox.net/~xenrox/ntfy-alertmanager/config"
	"golang.org/x/text/cases"


@@ 29,7 30,7 @@ var version = "dev"

type bridge struct {
	cfg    *config.Config
	logger *log.Logger
	logger *slog.Logger
	cache  cache.Cache
	client *httpClient
}


@@ 72,10 73,13 @@ func (br *bridge) singleAlertNotifications(p *payload) []*notification {
	for _, alert := range p.Alerts {
		contains, err := br.cache.Contains(alert.Fingerprint, alert.Status)
		if err != nil {
			br.logger.Errorf("Failed to lookup alert %q in cache: %v", alert.Fingerprint, err)
			br.logger.Error("Failed to lookup alert in cache",
				slog.String("fingerprint", alert.Fingerprint),
				slog.String("error", err.Error()))
		}
		if contains {
			br.logger.Debugf("Alert %q skipped: Still in cache", alert.Fingerprint)
			br.logger.Debug("Alert skipped: Still in cache",
				slog.String("fingerprint", alert.Fingerprint))
			continue
		}



@@ 161,7 165,8 @@ func (br *bridge) singleAlertNotifications(p *payload) []*notification {
				s := &silenceBody{AlertManagerURL: p.ExternalURL, Labels: alert.Labels}
				b, err := json.Marshal(s)
				if err != nil {
					br.logger.Errorf("Failed to create silence action: %v", err)
					br.logger.Error("Failed to create silence action",
						slog.String("error", err.Error()))
				}

				n.silenceBody = base64.StdEncoding.EncodeToString(b)


@@ 266,7 271,8 @@ func (br *bridge) multiAlertNotification(p *payload) *notification {
			s := &silenceBody{AlertManagerURL: p.ExternalURL, Labels: p.CommonLabels}
			b, err := json.Marshal(s)
			if err != nil {
				br.logger.Errorf("Failed to create silence action: %v", err)
				br.logger.Error("Failed to create silence action",
					slog.String("error", err.Error()))
			}

			n.silenceBody = base64.StdEncoding.EncodeToString(b)


@@ 332,7 338,8 @@ func (br *bridge) publish(n *notification) error {
	if resp.StatusCode != http.StatusOK {
		var ntfyError ntfyError
		if err := json.NewDecoder(resp.Body).Decode(&ntfyError); err != nil {
			br.logger.Debugf("Publish: failed to decode error: %v", err)
			br.logger.Debug("Publish: Failed to decode error",
				slog.String("error", err.Error()))
			return fmt.Errorf("ntfy: received status code %d", resp.StatusCode)
		}



@@ 347,36 354,39 @@ func (br *bridge) handleWebhooks(w http.ResponseWriter, r *http.Request) {

	if r.Method != http.MethodPost {
		http.Error(w, "Only POST allowed", http.StatusMethodNotAllowed)
		br.logger.Debugf("illegal HTTP method: expected %q, got %q", "POST", r.Method)
		br.logger.Debug(fmt.Sprintf("Illegal HTTP method: expected %q, got %q", "POST", r.Method))
		return
	}

	contentType := r.Header.Get("Content-Type")
	if contentType != "application/json" {
		http.Error(w, "Only application/json allowed", http.StatusUnsupportedMediaType)
		br.logger.Debugf("illegal content type: %s", contentType)
		br.logger.Debug(fmt.Sprintf("Illegal content type: %s", contentType))
		return
	}

	var event payload
	if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
		br.logger.Debug(err)
		br.logger.Debug("Failed to decode payload",
			slog.String("error", err.Error()))
		return
	}

	if br.logger.Level() == log.Debug {
		br.logger.Debugf("Received alert %+v", event)
	}
	br.logger.Debug("Received alert",
		slog.Any("payload", event))

	if br.cfg.AlertMode == config.Single {
		notifications := br.singleAlertNotifications(&event)
		for _, n := range notifications {
			err := br.publish(n)
			if err != nil {
				br.logger.Errorf("Failed to publish notification: %v", err)
				br.logger.Error("Failed to publish notification",
					slog.String("error", err.Error()))
			} else {
				if err := br.cache.Set(n.fingerprint, n.status); err != nil {
					br.logger.Errorf("Failed to set alert %q in cache: %v", n.fingerprint, err)
					br.logger.Error("Failed to cache alert",
						slog.String("fingerprint", n.fingerprint),
						slog.String("error", err.Error()))
				}
			}
		}


@@ 384,7 394,8 @@ func (br *bridge) handleWebhooks(w http.ResponseWriter, r *http.Request) {
		notification := br.multiAlertNotification(&event)
		err := br.publish(notification)
		if err != nil {
			br.logger.Errorf("Failed to publish notification: %v", err)
			br.logger.Error("Failed to publish notification",
				slog.String("error", err.Error()))
		}
	}
}


@@ 439,16 450,24 @@ func main() {
		os.Exit(0)
	}

	logger := log.NewDefaultLogger()
	logLevel := new(slog.LevelVar)
	logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
		Level: logLevel,
	}))

	cfg, err := config.ReadConfig(configPath)
	if err != nil {
		logger.Fatalf("Failed to read config: %v", err)
		logger.Error("Failed to read config",
			slog.String("error", err.Error()))
		os.Exit(1)
	}

	if err := logger.SetLevelFromString(cfg.LogLevel); err != nil {
		logger.Errorf("Failed to parse logging level: %v", err)
	level, err := logging.ParseLevelFromString(cfg.LogLevel)
	if err != nil {
		logger.Error("Failed to parse logging level",
			slog.String("error", err.Error()))
	}
	logLevel.Set(level)

	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()


@@ 457,11 476,13 @@ func main() {

	c, err := cache.NewCache(cfg.Cache)
	if err != nil {
		logger.Fatalf("Failed to create cache: %v", err)
		logger.Error("Failed to create cache",
			slog.String("error", err.Error()))
		os.Exit(1)
	}
	bridge := &bridge{cfg: cfg, logger: logger, cache: c, client: client}

	logger.Infof("Listening on %s, ntfy-alertmanager %s", cfg.HTTPAddress, version)
	logger.Info(fmt.Sprintf("Listening on %s, ntfy-alertmanager %s", cfg.HTTPAddress, version))

	mux := http.NewServeMux()
	mux.HandleFunc("/", bridge.handleWebhooks)


@@ 483,7 504,9 @@ func main() {
	go func() {
		err = httpServer.ListenAndServe()
		if err != nil && err != http.ErrServerClosed {
			logger.Fatalf("Failed to start HTTP server: %v", err)
			logger.Error("Failed to start HTTP server",
				slog.String("error", err.Error()))
			os.Exit(1)
		}
	}()



@@ 495,6 518,7 @@ func main() {

	err = httpServer.Shutdown(httpShutdownContext)
	if err != nil {
		logger.Errorf("Failed to shutdown HTTP server: %v", err)
		logger.Error("Failed to shutdown HTTP server",
			slog.String("error", err.Error()))
	}
}

M silence.go => silence.go +23 -11
@@ 4,7 4,9 @@ import (
	"bytes"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"time"
)


@@ 40,26 42,29 @@ func (br *bridge) handleSilences(w http.ResponseWriter, r *http.Request) {

	if r.Method != http.MethodPost {
		http.Error(w, "Only POST allowed", http.StatusMethodNotAllowed)
		br.logger.Debugf("silences: illegal HTTP method: expected %q, got %q", "POST", r.Method)
		br.logger.Debug(fmt.Sprintf("Silences: Illegal HTTP method: expected %q, got %q", "POST", r.Method))
		return
	}

	b, err := io.ReadAll(r.Body)
	if err != nil {
		br.logger.Debugf("silences: %v", err)
		br.logger.Error("Silences: Failed to read body",
			slog.String("error", err.Error()))
		return
	}

	b, err = base64.StdEncoding.DecodeString(string(b))
	if err != nil {
		br.logger.Debugf("silences: %v", err)
		br.logger.Error("Silences: Failed to decode",
			slog.String("error", err.Error()))
		return
	}

	var sb silenceBody
	err = json.Unmarshal(b, &sb)
	if err != nil {
		br.logger.Debugf("silences: %v", err)
		br.logger.Error("Silences: Failed to unmarshal",
			slog.String("error", err.Error()))
		return
	}



@@ 85,7 90,8 @@ func (br *bridge) handleSilences(w http.ResponseWriter, r *http.Request) {

	b, err = json.Marshal(silence)
	if err != nil {
		br.logger.Debugf("silences: %v", err)
		br.logger.Error("Silences: Failed to marshal",
			slog.String("error", err.Error()))
		return
	}



@@ 97,7 103,8 @@ func (br *bridge) handleSilences(w http.ResponseWriter, r *http.Request) {

	req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(b))
	if err != nil {
		br.logger.Debugf("silences: %v", err)
		br.logger.Error("Silences: Failed to create request",
			slog.String("error", err.Error()))
		return
	}



@@ 109,27 116,32 @@ func (br *bridge) handleSilences(w http.ResponseWriter, r *http.Request) {
	req.Header.Add("Content-Type", "application/json")
	resp, err := br.client.Do(req)
	if err != nil {
		br.logger.Debugf("silences: %v", err)
		br.logger.Error("Silences: Failed to POST request",
			slog.String("error", err.Error()))
		return
	}
	defer resp.Body.Close()

	b, err = io.ReadAll(resp.Body)
	if err != nil {
		br.logger.Debugf("silences: %v", err)
		br.logger.Error("Silences: Failed to read response body",
			slog.String("error", err.Error()))
		return
	}

	if resp.StatusCode != http.StatusOK {
		br.logger.Debugf("silences: received status code %d", resp.StatusCode)
		br.logger.Error("Silences: Received non-200 status code",
			slog.Int("status", resp.StatusCode))
		return
	}

	var id silenceResponse
	if err := json.Unmarshal(b, &id); err != nil {
		br.logger.Debugf("silences: %v", err)
		br.logger.Error("Silences: Failed to unmarshal response",
			slog.String("error", err.Error()))
		return
	}

	br.logger.Infof("Created new silence %s", id.ID)
	br.logger.Info("Silences: Created new silence",
		slog.String("ID", id.ID))
}