Graph the metric behind each alert on the incident page

Each alert carries a generatorURL pointing at the Prometheus rule that fired
it. The server now parses that link, queries the same Prometheus for the
rule's expression and draws it under the alert, with the current value
beside it. The browser never talks to Prometheus, so the CSP stays at
connect-src 'self'.

The URL comes out of a webhook payload, so whoever holds an integration key
chooses it. Querying it is therefore opt-in and bounded by an allowlist,
TERDUT_PROMETHEUS_ALLOWED_HOSTS (names, "*" as one label, a leading "**" as
several, IPs, CIDRs). The check runs on the address the dialer is about to
use, a name that resolves to loopback or link-local is refused even when it
matches, and redirects and proxy variables are ignored.

A worker takes a snapshot when an incident opens and another when it
closes, so the graph outlives Prometheus' retention. It finds its work in
the database rather than being triggered from the webhook, so ingestion is
untouched and a restart loses nothing. While an incident is open the
metrics endpoint also queries live, cached for 15 seconds.

The rule expression keeps its threshold, so Prometheus returns data only
while the alert condition holds; "no data" for the current value means the
condition cleared. Stripping the threshold needs a PromQL parser and is left
out.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
This commit is contained in:
Niklas Ye
2026-10-22 14:10:34 +02:00
parent 94c394e8f1
commit f344bb707b
22 changed files with 1909 additions and 2 deletions
+268
View File
@@ -0,0 +1,268 @@
// Package prom fetches metrics from the Prometheus an alert came from.
//
// The address to query is the alert's generatorURL, which arrives in a webhook
// payload and is therefore chosen by whoever holds an integration key. Left
// unchecked, that would let the key holder make this server issue requests to
// any host it can reach. Everything here is built around the Allowlist so that
// cannot happen: a host is queried only if the operator listed it.
package prom
import (
"fmt"
"net"
"net/netip"
"net/url"
"path"
"strings"
)
// Allowlist is the set of Prometheus servers this install may query. The zero
// value, and an empty list, permit nothing, which is what keeps the feature off
// until an operator opts in.
//
// An entry is one of:
//
// - a host name, optionally with a port: prometheus.monitoring.svc, prom:9090
// - a wildcard host name: "*" stands for exactly one DNS label (or part of
// one), so *.example.com matches a.example.com but neither example.com nor
// a.b.example.com, and prometheus-*.monitoring.svc matches prometheus-0.
// A leading "**" label matches one or more labels, so **.example.com also
// matches a.b.example.com.
// - an IP address or a CIDR range, optionally with a port: 10.0.0.0/8,
// [fd00::]:9090
//
// Without a port an entry matches any port. Matching is case-insensitive.
type Allowlist struct {
hosts []hostPattern
nets []netEntry
}
type hostPattern struct {
labels []string // lower-cased; a leading "**" label is kept as is
port string // empty matches any port
}
type netEntry struct {
prefix netip.Prefix
port string
}
// ParseAllowlist validates and compiles the configured entries. It refuses an
// entry that would admit every host (a bare "*", "*.*"): a typo there would
// quietly turn the guard off.
func ParseAllowlist(entries []string) (*Allowlist, error) {
a := &Allowlist{}
for _, raw := range entries {
e := strings.ToLower(strings.TrimSpace(raw))
if e == "" {
continue
}
if err := a.add(e); err != nil {
return nil, fmt.Errorf("allowed host %q: %w", raw, err)
}
}
return a, nil
}
func (a *Allowlist) add(e string) error {
// CIDR first: its colons (IPv6) and slash would confuse the host:port split.
if strings.Contains(e, "/") {
host, port := e, ""
if strings.HasPrefix(e, "[") {
end := strings.Index(e, "]")
if end < 0 {
return fmt.Errorf("unterminated [")
}
host = e[1:end]
rest := e[end+1:]
if strings.HasPrefix(rest, ":") {
port = rest[1:]
}
}
p, err := netip.ParsePrefix(host)
if err != nil {
return fmt.Errorf("not a CIDR range: %w", err)
}
if err := checkPort(port); err != nil {
return err
}
a.nets = append(a.nets, netEntry{prefix: p.Masked(), port: port})
return nil
}
// A bare IP address, IPv6 included.
if ip, err := netip.ParseAddr(e); err == nil {
a.nets = append(a.nets, netEntry{prefix: netip.PrefixFrom(ip.Unmap(), ip.Unmap().BitLen())})
return nil
}
host, port := e, ""
if strings.HasPrefix(e, "[") || strings.Count(e, ":") == 1 {
h, p, err := net.SplitHostPort(e)
if err != nil {
return fmt.Errorf("bad host:port: %w", err)
}
host, port = h, p
if ip, err := netip.ParseAddr(host); err == nil {
if err := checkPort(port); err != nil {
return err
}
a.nets = append(a.nets, netEntry{prefix: netip.PrefixFrom(ip.Unmap(), ip.Unmap().BitLen()), port: port})
return nil
}
}
if err := checkPort(port); err != nil {
return err
}
host = strings.TrimSuffix(host, ".")
if host == "" {
return fmt.Errorf("empty host")
}
for _, r := range host {
ok := r == '.' || r == '-' || r == '_' || r == '*' || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
if !ok {
return fmt.Errorf("unexpected character %q", r)
}
}
labels := strings.Split(host, ".")
for i, l := range labels {
if l == "" {
return fmt.Errorf("empty label")
}
if l == "**" && i != 0 {
return fmt.Errorf("** is only allowed as the first label")
}
if strings.Contains(l, "**") && l != "**" {
return fmt.Errorf("** must be a whole label")
}
}
// The last label has to say something, or the entry is allow-all.
if strings.Trim(labels[len(labels)-1], "*") == "" {
return fmt.Errorf("would match every host: the last label cannot be a wildcard alone")
}
a.hosts = append(a.hosts, hostPattern{labels: labels, port: port})
return nil
}
func checkPort(p string) error {
if p == "" {
return nil
}
for _, r := range p {
if r < '0' || r > '9' {
return fmt.Errorf("bad port %q", p)
}
}
return nil
}
// Empty reports whether nothing is allowed, i.e. the feature is off.
func (a *Allowlist) Empty() bool {
return a == nil || (len(a.hosts) == 0 && len(a.nets) == 0)
}
// Port returns the effective port of u: the explicit one, else the scheme's.
func Port(u *url.URL) string {
if p := u.Port(); p != "" {
return p
}
if u.Scheme == "https" {
return "443"
}
return "80"
}
// permits decides whether host:port may be contacted at ip. host is what the
// URL said (a name or an IP literal), ip is where it actually resolved to; the
// check runs on the resolved address at dial time so that a name that resolves
// somewhere else than it did when it was listed (DNS rebinding) is caught.
//
// - An address inside a listed CIDR range or equal to a listed IP is allowed.
// - A host name that matches a listed pattern is allowed, unless it resolved
// to an address that is never a Prometheus (loopback, link-local, which
// includes the cloud metadata address, unspecified, multicast). A wildcard
// over a domain somebody else controls must not be a way to reach internal
// services. To allow such an address deliberately, list it as a CIDR.
func (a *Allowlist) permits(host, port string, ip netip.Addr) bool {
if a == nil {
return false
}
ip = ip.Unmap()
for _, n := range a.nets {
if n.prefix.Contains(ip) && (n.port == "" || n.port == port) {
return true
}
}
if forbiddenAddr(ip) {
return false
}
if _, isIP := parseHostIP(host); isIP {
return false // an IP literal is allowed only through a CIDR entry
}
return a.matchesName(host, port)
}
func parseHostIP(host string) (netip.Addr, bool) {
ip, err := netip.ParseAddr(strings.Trim(host, "[]"))
return ip, err == nil
}
// MatchesHost reports whether the URL's host is worth dialing at all: a listed
// name, or an IP that falls in a listed range. It is the cheap early answer;
// the dial-time check in permits is the one that is authoritative for names.
func (a *Allowlist) MatchesHost(u *url.URL) bool {
if a.Empty() {
return false
}
host := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
port := Port(u)
if ip, ok := parseHostIP(host); ok {
return a.permits(host, port, ip)
}
if a.matchesName(host, port) {
return true
}
// A name outside the patterns can still be admitted by a CIDR entry once it
// resolves; leave that to the dialer.
return len(a.nets) > 0
}
func (a *Allowlist) matchesName(host, port string) bool {
labels := strings.Split(strings.TrimSuffix(strings.ToLower(host), "."), ".")
for _, p := range a.hosts {
if (p.port == "" || p.port == port) && matchLabels(p.labels, labels) {
return true
}
}
return false
}
func matchLabels(pattern, host []string) bool {
if len(pattern) > 0 && pattern[0] == "**" {
rest := pattern[1:]
if len(host) <= len(rest) {
return false
}
return matchLabels(rest, host[len(host)-len(rest):])
}
if len(pattern) != len(host) {
return false
}
for i := range pattern {
// The pattern was restricted to [a-z0-9_.-*] when parsed, so path.Match
// sees nothing but literals and "*" here.
if ok, _ := path.Match(pattern[i], host[i]); !ok {
return false
}
}
return true
}
// forbiddenAddr is the set of addresses a Prometheus is never reached at and
// that are dangerous to reach by mistake. Private ranges are deliberately not
// in it: a Prometheus inside the cluster lives there.
func forbiddenAddr(ip netip.Addr) bool {
return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified()
}
+274
View File
@@ -0,0 +1,274 @@
package prom
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net"
"net/http"
"net/netip"
"net/url"
"strconv"
"strings"
"time"
)
const (
// maxResponseBytes bounds what one query may make us read.
maxResponseBytes = 5 << 20
// maxSeries bounds how many series one query may return before we give up on
// it; a rule expression without aggregation can match thousands.
maxSeries = 2000
// TargetPoints is how many points a graph is asked for.
TargetPoints = 300
)
// ErrNotAllowed means the target host is not on the allowlist.
var ErrNotAllowed = errors.New("host is not in the allowed Prometheus hosts")
// Series is one time series, ready to chart. Points are [unix seconds, value].
type Series struct {
Labels map[string]string `json:"labels"`
Points [][2]float64 `json:"points"`
}
// Client queries Prometheus servers that the Allowlist permits.
type Client struct {
http *http.Client
allow *Allowlist
}
// NewClient returns a client that only ever connects to allowed addresses. The
// check is made on the address the dialer is about to use, after name
// resolution, and redirects and environment proxies are off: either would let a
// permitted host send the request somewhere that is not.
func NewClient(allow *Allowlist, timeout time.Duration) *Client {
if timeout <= 0 {
timeout = 10 * time.Second
}
dialer := &net.Dialer{Timeout: timeout}
tr := &http.Transport{
Proxy: nil,
DialContext: guardedDial(allow, dialer),
TLSHandshakeTimeout: timeout,
MaxIdleConns: 4,
IdleConnTimeout: 30 * time.Second,
}
return &Client{
allow: allow,
http: &http.Client{
Timeout: timeout,
Transport: tr,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
},
}
}
// Enabled reports whether any host is allowed at all.
func (c *Client) Enabled() bool { return c != nil && !c.allow.Empty() }
// Allowed is the early, resolution-free answer: whether base could be queried.
func (c *Client) Allowed(base *url.URL) bool {
return c != nil && c.allow.MatchesHost(base)
}
func guardedDial(allow *Allowlist, d *net.Dialer) func(ctx context.Context, network, addr string) (net.Conn, error) {
return func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
var ips []netip.Addr
if ip, err := netip.ParseAddr(host); err == nil {
ips = []netip.Addr{ip}
} else {
ips, err = net.DefaultResolver.LookupNetIP(ctx, "ip", host)
if err != nil {
return nil, err
}
}
var lastErr error = ErrNotAllowed
for _, ip := range ips {
if !allow.permits(host, port, ip) {
continue
}
// Dial the checked address itself, so a second lookup cannot return
// something different.
conn, err := d.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if err == nil {
return conn, nil
}
lastErr = err
}
return nil, lastErr
}
}
// QueryRange runs expr over [start, end] at the given step.
func (c *Client) QueryRange(ctx context.Context, base *url.URL, expr string, start, end time.Time, step time.Duration) ([]Series, error) {
form := url.Values{
"query": {expr},
"start": {strconv.FormatInt(start.Unix(), 10)},
"end": {strconv.FormatInt(end.Unix(), 10)},
"step": {strconv.FormatInt(int64(step/time.Second), 10)},
}
res, err := c.do(ctx, base, "/api/v1/query_range", form)
if err != nil {
return nil, err
}
if res.ResultType != "matrix" {
return nil, fmt.Errorf("unexpected result type %q", res.ResultType)
}
var raw []struct {
Metric map[string]string `json:"metric"`
Values [][2]json.RawMessage `json:"values"`
}
if err := json.Unmarshal(res.Result, &raw); err != nil {
return nil, fmt.Errorf("decode matrix: %w", err)
}
out := make([]Series, 0, len(raw))
for _, r := range raw {
s := Series{Labels: r.Metric, Points: make([][2]float64, 0, len(r.Values))}
for _, v := range r.Values {
if p, ok := point(v); ok {
s.Points = append(s.Points, p)
}
}
out = append(out, s)
}
return out, nil
}
// Query runs expr at a single instant and returns one point per series.
func (c *Client) Query(ctx context.Context, base *url.URL, expr string, at time.Time) ([]Series, error) {
form := url.Values{
"query": {expr},
"time": {strconv.FormatInt(at.Unix(), 10)},
}
res, err := c.do(ctx, base, "/api/v1/query", form)
if err != nil {
return nil, err
}
if res.ResultType != "vector" {
return nil, fmt.Errorf("unexpected result type %q", res.ResultType)
}
var raw []struct {
Metric map[string]string `json:"metric"`
Value [2]json.RawMessage `json:"value"`
}
if err := json.Unmarshal(res.Result, &raw); err != nil {
return nil, fmt.Errorf("decode vector: %w", err)
}
out := make([]Series, 0, len(raw))
for _, r := range raw {
s := Series{Labels: r.Metric}
if p, ok := point(r.Value); ok {
s.Points = [][2]float64{p}
}
out = append(out, s)
}
return out, nil
}
type apiResult struct {
ResultType string `json:"resultType"`
Result json.RawMessage `json:"result"`
}
func (c *Client) do(ctx context.Context, base *url.URL, apiPath string, form url.Values) (*apiResult, error) {
if !c.Allowed(base) {
return nil, ErrNotAllowed
}
u := *base
u.Path = strings.TrimSuffix(base.Path, "/") + apiPath
// POST, so a long expression is not limited by URL length.
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
if err != nil {
return nil, err
}
if len(body) > maxResponseBytes {
return nil, errors.New("response too large")
}
var env struct {
Status string `json:"status"`
Data *apiResult `json:"data"`
ErrorType string `json:"errorType"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &env); err != nil {
return nil, fmt.Errorf("prometheus answered %d with a body that is not JSON", resp.StatusCode)
}
if resp.StatusCode/100 != 2 || env.Status != "success" || env.Data == nil {
msg := env.Error
if msg == "" {
msg = "status " + strconv.Itoa(resp.StatusCode)
}
return nil, fmt.Errorf("prometheus: %s", truncate(msg, 200))
}
if n := countResults(env.Data.Result); n > maxSeries {
return nil, fmt.Errorf("query matches %d series, over the limit of %d", n, maxSeries)
}
return env.Data, nil
}
func countResults(raw json.RawMessage) int {
var items []json.RawMessage
if json.Unmarshal(raw, &items) != nil {
return 0
}
return len(items)
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// point decodes a [timestamp, "value"] pair. Prometheus writes values as
// strings so that NaN and ±Inf survive; JSON cannot carry them, so those points
// are dropped.
func point(v [2]json.RawMessage) ([2]float64, bool) {
var ts float64
var s string
if json.Unmarshal(v[0], &ts) != nil || json.Unmarshal(v[1], &s) != nil {
return [2]float64{}, false
}
f, err := strconv.ParseFloat(s, 64)
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
return [2]float64{}, false
}
return [2]float64{ts, f}, true
}
// StepFor picks a query step that yields about TargetPoints over the span, never
// finer than a second.
func StepFor(start, end time.Time) time.Duration {
span := end.Sub(start)
step := span / TargetPoints
if step < time.Second {
return time.Second
}
return step.Round(time.Second)
}
+51
View File
@@ -0,0 +1,51 @@
package prom
import "sort"
// MaxSeriesPerAlert caps how many series are kept for one alert.
const MaxSeriesPerAlert = 5
// SelectSeries keeps the series that belong to this alert. The rule expression
// returns every series it matches, not just the one that fired, so a series is
// kept only if it agrees with the alert's labels on every label name they both
// carry. Labels only one side has (the rule's severity, the series' __name__)
// say nothing either way, and alertname is the rule's, not the series'.
//
// The result is capped at MaxSeriesPerAlert, best-labelled first: the more
// labels a series shares with the alert, the likelier it is the one that fired.
func SelectSeries(series []Series, alertLabels map[string]string) []Series {
type scored struct {
s Series
score int
}
var kept []scored
for _, s := range series {
score, ok := 0, true
for k, v := range s.Labels {
if k == "__name__" || k == "alertname" {
continue
}
av, has := alertLabels[k]
if !has {
continue
}
if av != v {
ok = false
break
}
score++
}
if ok && len(s.Points) > 0 {
kept = append(kept, scored{s, score})
}
}
sort.SliceStable(kept, func(i, j int) bool { return kept[i].score > kept[j].score })
if len(kept) > MaxSeriesPerAlert {
kept = kept[:MaxSeriesPerAlert]
}
out := make([]Series, len(kept))
for i, k := range kept {
out[i] = k.s
}
return out
}
+38
View File
@@ -0,0 +1,38 @@
package prom
import (
"net/url"
"strings"
)
// ParseGeneratorURL splits an Alertmanager generatorURL into the Prometheus it
// points at and the expression of the rule that fired. Prometheus builds it as
// <external-url>/graph?g0.expr=<expr>&g0.tab=1 (/query on Prometheus 3), and the
// external URL may carry a path prefix, which is kept in base.
//
// ok is false for anything else: a non-HTTP scheme, credentials in the URL, a
// page that is not the graph page, or no expression. The caller then has
// nothing to query and falls back to showing the link.
func ParseGeneratorURL(raw string) (base *url.URL, expr string, ok bool) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Host == "" || u.User != nil {
return nil, "", false
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, "", false
}
prefix := ""
switch {
case strings.HasSuffix(u.Path, "/graph"):
prefix = strings.TrimSuffix(u.Path, "/graph")
case strings.HasSuffix(u.Path, "/query"):
prefix = strings.TrimSuffix(u.Path, "/query")
default:
return nil, "", false
}
expr = strings.TrimSpace(u.Query().Get("g0.expr"))
if expr == "" {
return nil, "", false
}
return &url.URL{Scheme: u.Scheme, Host: u.Host, Path: prefix}, expr, true
}
+237
View File
@@ -0,0 +1,237 @@
package prom
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"testing"
"time"
)
func TestParseAllowlistRejectsAllowAll(t *testing.T) {
for _, e := range []string{"*", "**", "*.*", "**.*", "a.**", "pro**.example.com", "ex ample.com", "host:abc", "10.0.0.0/33"} {
if _, err := ParseAllowlist([]string{e}); err == nil {
t.Errorf("%q was accepted", e)
}
}
}
func TestAllowlistNames(t *testing.T) {
a, err := ParseAllowlist([]string{
"prometheus.monitoring.svc",
"prom-*.monitoring.svc",
"*.example.com",
"**.corp.example",
"fixed.example.org:9090",
})
if err != nil {
t.Fatal(err)
}
pub := netip.MustParseAddr("10.1.2.3")
cases := []struct {
host, port string
want bool
}{
{"prometheus.monitoring.svc", "9090", true},
{"PROMETHEUS.monitoring.svc", "80", true},
{"prom-0.monitoring.svc", "9090", true},
{"prom-0.other.svc", "9090", false},
{"a.example.com", "443", true},
{"example.com", "443", false},
{"a.b.example.com", "443", false},
{"a.corp.example", "80", true},
{"a.b.c.corp.example", "80", true},
{"corp.example", "80", false},
{"fixed.example.org", "9090", true},
{"fixed.example.org", "80", false},
{"evilexample.com", "80", false},
{"a.example.com.evil.net", "80", false},
}
for _, c := range cases {
if got := a.permits(c.host, c.port, pub); got != c.want {
t.Errorf("permits(%s:%s) = %v, want %v", c.host, c.port, got, c.want)
}
}
}
func TestAllowlistForbiddenAddressesAndCIDR(t *testing.T) {
a, _ := ParseAllowlist([]string{"*.example.com", "192.168.0.0/16", "[fd00::]:9090", "127.0.0.9"})
cases := []struct {
name, host, port, ip string
want bool
}{
{"wildcard to private is fine", "a.example.com", "80", "10.0.0.5", true},
{"wildcard rebinding to loopback", "a.example.com", "80", "127.0.0.1", false},
{"wildcard to metadata", "a.example.com", "80", "169.254.169.254", false},
{"wildcard to unspecified", "a.example.com", "80", "0.0.0.0", false},
{"wildcard to v4-mapped loopback", "a.example.com", "80", "::ffff:127.0.0.1", false},
{"cidr literal", "192.168.4.5", "80", "192.168.4.5", true},
{"outside cidr", "10.9.9.9", "80", "10.9.9.9", false},
{"cidr admits unlisted name", "anything.test", "80", "192.168.1.1", true},
{"explicit loopback entry", "127.0.0.9", "80", "127.0.0.9", true},
{"other loopback", "127.0.0.1", "80", "127.0.0.1", false},
{"ipv6 with port ok", "fd00::1", "9090", "fd00::1", false}, // entry is a single address fd00::, not the range
{"ipv6 exact", "fd00::", "9090", "fd00::", true},
{"ipv6 wrong port", "fd00::", "80", "fd00::", false},
}
for _, c := range cases {
if got := a.permits(c.host, c.port, netip.MustParseAddr(c.ip)); got != c.want {
t.Errorf("%s: permits = %v, want %v", c.name, got, c.want)
}
}
}
func TestEmptyAllowlistPermitsNothing(t *testing.T) {
var nilList *Allowlist
empty, _ := ParseAllowlist(nil)
for _, a := range []*Allowlist{nilList, empty} {
if !a.Empty() || a.permits("x.example.com", "80", netip.MustParseAddr("10.0.0.1")) {
t.Error("empty allowlist permitted something")
}
}
}
func TestParseGeneratorURL(t *testing.T) {
cases := []struct {
raw string
wantBase string
wantExpr string
ok bool
}{
{"http://prom:9090/graph?g0.expr=up%3D%3D0&g0.tab=1", "http://prom:9090", "up==0", true},
{"https://p.example.com/prom/graph?g0.expr=rate%28x%5B5m%5D%29%3E1", "https://p.example.com/prom", "rate(x[5m])>1", true},
{"https://p.example.com/query?g0.expr=up", "https://p.example.com", "up", true},
{"http://prom:9090/graph?g0.tab=1", "", "", false},
{"http://prom:9090/other?g0.expr=up", "", "", false},
{"ftp://prom/graph?g0.expr=up", "", "", false},
{"http://user:pw@prom/graph?g0.expr=up", "", "", false},
{"", "", "", false},
{"not a url", "", "", false},
}
for _, c := range cases {
base, expr, ok := ParseGeneratorURL(c.raw)
if ok != c.ok || expr != c.wantExpr || (ok && base.String() != c.wantBase) {
t.Errorf("%q = (%v, %q, %v), want (%q, %q, %v)", c.raw, base, expr, ok, c.wantBase, c.wantExpr, c.ok)
}
}
}
func TestSelectSeries(t *testing.T) {
mk := func(l map[string]string) Series { return Series{Labels: l, Points: [][2]float64{{1, 1}}} }
series := []Series{
mk(map[string]string{"__name__": "up", "instance": "a", "job": "x"}),
mk(map[string]string{"__name__": "up", "instance": "b", "job": "x"}),
mk(map[string]string{"instance": "a", "job": "y"}),
{Labels: map[string]string{"instance": "a"}}, // no points
}
got := SelectSeries(series, map[string]string{"alertname": "Down", "instance": "a", "job": "x", "severity": "page"})
if len(got) != 1 || got[0].Labels["job"] != "x" || got[0].Labels["instance"] != "a" {
t.Fatalf("got %+v", got)
}
if n := len(SelectSeries(series, nil)); n != 3 {
t.Errorf("no alert labels should keep every series with points, got %d", n)
}
}
func TestStepFor(t *testing.T) {
now := time.Now()
if s := StepFor(now.Add(-time.Minute), now); s != time.Second {
t.Errorf("short span step = %v", s)
}
if s := StepFor(now.Add(-5*time.Hour), now); s != time.Minute {
t.Errorf("5h step = %v, want 1m", s)
}
}
func fakeProm(t *testing.T) (*httptest.Server, *int) {
t.Helper()
hits := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
r.ParseForm()
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/query_range":
w.Write([]byte(`{"status":"success","data":{"resultType":"matrix","result":[
{"metric":{"instance":"a"},"values":[[100,"1"],[110,"NaN"],[120,"2.5"]]}]}}`))
case "/api/v1/query":
w.Write([]byte(`{"status":"success","data":{"resultType":"vector","result":[
{"metric":{"instance":"a"},"value":[130,"3"]}]}}`))
case "/redirect/api/v1/query":
http.Redirect(w, r, "http://127.0.0.1:1/", http.StatusFound)
default:
w.WriteHeader(400)
w.Write([]byte(`{"status":"error","errorType":"bad_data","error":"nope"}`))
}
}))
t.Cleanup(srv.Close)
return srv, &hits
}
func TestClientQueries(t *testing.T) {
srv, _ := fakeProm(t)
u, _ := url.Parse(srv.URL)
// httptest listens on loopback, which is refused unless listed as a CIDR.
allow, _ := ParseAllowlist([]string{"127.0.0.0/8"})
c := NewClient(allow, 5*time.Second)
got, err := c.QueryRange(context.Background(), u, "up", time.Unix(100, 0), time.Unix(120, 0), 10*time.Second)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || len(got[0].Points) != 2 || got[0].Points[1] != [2]float64{120, 2.5} {
t.Fatalf("range = %+v (NaN point should be dropped)", got)
}
inst, err := c.Query(context.Background(), u, "up", time.Unix(130, 0))
if err != nil || len(inst) != 1 || inst[0].Points[0][1] != 3 {
t.Fatalf("instant = %+v, %v", inst, err)
}
}
func TestClientRefusesUnlistedAndLoopback(t *testing.T) {
srv, hits := fakeProm(t)
u, _ := url.Parse(srv.URL)
// Listed by name, but the name resolves to loopback: refused at dial time.
local, _ := url.Parse("http://localhost:" + u.Port())
wild, _ := ParseAllowlist([]string{"local*"})
if _, err := NewClient(wild, time.Second).Query(context.Background(), local, "up", time.Now()); err == nil {
t.Error("wildcard-matched name resolving to loopback was queried")
}
// Not listed at all.
other, _ := ParseAllowlist([]string{"*.example.com"})
if _, err := NewClient(other, time.Second).Query(context.Background(), u, "up", time.Now()); !errors.Is(err, ErrNotAllowed) {
t.Errorf("unlisted host: err = %v", err)
}
// Disabled.
if _, err := NewClient(nil, time.Second).Query(context.Background(), u, "up", time.Now()); !errors.Is(err, ErrNotAllowed) {
t.Errorf("disabled: err = %v", err)
}
if *hits != 0 {
t.Errorf("server was contacted %d times", *hits)
}
}
func TestClientDoesNotFollowRedirects(t *testing.T) {
srv, _ := fakeProm(t)
u, _ := url.Parse(srv.URL + "/redirect")
allow, _ := ParseAllowlist([]string{"127.0.0.0/8"})
if _, err := NewClient(allow, time.Second).Query(context.Background(), u, "up", time.Now()); err == nil {
t.Error("redirect response was treated as success")
}
}
func TestClientSurfacesPrometheusError(t *testing.T) {
srv, _ := fakeProm(t)
u, _ := url.Parse(srv.URL + "/missing")
allow, _ := ParseAllowlist([]string{"127.0.0.0/8"})
_, err := NewClient(allow, time.Second).Query(context.Background(), u, "up", time.Now())
if err == nil {
t.Fatal("expected an error")
}
}