1175 lines
46 KiB
Go
1175 lines
46 KiB
Go
package main
|
|
|
|
// Tests for the pull half of the observe family: the endpoints file and its
|
|
// every refusal, the three ops driven end to end through the real dispatcher,
|
|
// and the heartbeat's `alerts` contract.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// withEndpointsFile writes one observe-endpoints.json and points the loader at
|
|
// it for the duration of the test.
|
|
func withEndpointsFile(t *testing.T, content string) func() {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "observe-endpoints.json")
|
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
orig := observeEndpointsPath
|
|
observeEndpointsPath = path
|
|
return func() { observeEndpointsPath = orig }
|
|
}
|
|
|
|
// withNoEndpointsFile points the loader at a path that does not exist, so a box
|
|
// running the suite cannot leak its own real file into a test.
|
|
func withNoEndpointsFile(t *testing.T) func() {
|
|
t.Helper()
|
|
orig := observeEndpointsPath
|
|
observeEndpointsPath = filepath.Join(t.TempDir(), "absent.json")
|
|
return func() { observeEndpointsPath = orig }
|
|
}
|
|
|
|
// ── the endpoints file ───────────────────────────────────────────────────────
|
|
|
|
// The allow list is the INVERSE of observe:net's. Every address class that op
|
|
// refuses, this one must accept — and the public addresses it exists to probe
|
|
// are exactly what this one must refuse.
|
|
func TestObserveEndpointAllowedIsTheInverseOfObserveNet(t *testing.T) {
|
|
private := []string{"127.0.0.1", "10.1.2.3", "172.16.0.1", "172.31.255.255", "192.168.1.1",
|
|
"100.64.0.1", "100.127.255.255", "169.254.169.254", "0.0.0.0", "::1", "::", "fc00::1",
|
|
"fd12::1", "fe80::1", "::ffff:10.0.0.1", "10.99.1.1"}
|
|
for _, ip := range private {
|
|
a := netip.MustParseAddr(ip)
|
|
if !observeEndpointAllowed(a) {
|
|
t.Errorf("%s must be ALLOWED here — it is the box's own neighbourhood", ip)
|
|
}
|
|
if isPublicAddr(a) {
|
|
t.Errorf("%s: the two checks disagree; they must be exact opposites", ip)
|
|
}
|
|
}
|
|
for _, ip := range []string{"203.0.113.10", "8.8.8.8", "172.32.0.1", "100.128.0.1", "2606:4700::1111"} {
|
|
a := netip.MustParseAddr(ip)
|
|
if observeEndpointAllowed(a) {
|
|
t.Errorf("%s is PUBLIC and must be refused — this file may not make the box fetch the internet", ip)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestObserveEndpointsFileParsing(t *testing.T) {
|
|
// A stubbed resolver so the hostname cases need no DNS.
|
|
orig := netResolve
|
|
netResolve = func(_ context.Context, host string) ([]netip.Addr, error) {
|
|
switch host {
|
|
case "alerts.internal":
|
|
return []netip.Addr{netip.MustParseAddr("10.99.1.1")}, nil
|
|
case "split.internal":
|
|
return []netip.Addr{netip.MustParseAddr("10.0.0.5"), netip.MustParseAddr("203.0.113.10")}, nil
|
|
case "public.example":
|
|
return []netip.Addr{netip.MustParseAddr("203.0.113.10")}, nil
|
|
case "empty.internal":
|
|
return nil, nil
|
|
}
|
|
return nil, errors.New("no such host")
|
|
}
|
|
defer func() { netResolve = orig }()
|
|
|
|
cases := []struct {
|
|
name string
|
|
content string
|
|
wantErr string // "" = accepted; otherwise a substring the message must carry
|
|
check func(t *testing.T, e observeEndpoints)
|
|
}{
|
|
{name: "all three, IP literals",
|
|
content: `{"alertmanager":"http://10.99.1.1:9093","prometheus":"http://10.99.1.2:9090","loki":"http://10.99.1.3:3100"}`,
|
|
check: func(t *testing.T, e observeEndpoints) {
|
|
if e.Alertmanager != "http://10.99.1.1:9093" || e.Prometheus != "http://10.99.1.2:9090" ||
|
|
e.Loki != "http://10.99.1.3:3100" {
|
|
t.Fatalf("%+v", e)
|
|
}
|
|
}},
|
|
{name: "loopback is allowed", content: `{"loki":"http://127.0.0.1:3100"}`},
|
|
{name: "ULA is allowed", content: `{"loki":"http://[fd00:1::10]:3100"}`},
|
|
{name: "link-local is allowed", content: `{"loki":"http://169.254.1.1:3100"}`},
|
|
{name: "CGNAT is allowed", content: `{"loki":"http://100.64.0.1:3100"}`},
|
|
{name: "https is allowed", content: `{"prometheus":"https://10.0.0.9:9090"}`},
|
|
{name: "a private hostname is allowed", content: `{"alertmanager":"http://alerts.internal:9093"}`},
|
|
{name: "one key only, the rest empty", content: `{"alertmanager":"http://10.99.1.1:9093"}`,
|
|
check: func(t *testing.T, e observeEndpoints) {
|
|
if e.Prometheus != "" || e.Loki != "" {
|
|
t.Fatalf("%+v", e)
|
|
}
|
|
}},
|
|
{name: "a trailing slash normalizes", content: `{"loki":"http://10.0.0.1:3100/"}`,
|
|
check: func(t *testing.T, e observeEndpoints) {
|
|
if e.Loki != "http://10.0.0.1:3100" {
|
|
t.Fatalf("%q", e.Loki)
|
|
}
|
|
}},
|
|
|
|
{name: "a public literal is refused", content: `{"alertmanager":"http://203.0.113.10:9093"}`,
|
|
wantErr: "alertmanager: 203.0.113.10 is a public address"},
|
|
{name: "a public hostname is refused", content: `{"loki":"http://public.example:3100"}`,
|
|
wantErr: "loki: 203.0.113.10 is a public address"},
|
|
{name: "one public record refuses the whole name", content: `{"prometheus":"http://split.internal:9090"}`,
|
|
wantErr: "prometheus: 203.0.113.10 is a public address"},
|
|
{name: "an unresolvable name is refused", content: `{"loki":"http://nx.internal:3100"}`,
|
|
wantErr: "loki: resolve nx.internal"},
|
|
{name: "a name with no addresses is refused", content: `{"loki":"http://empty.internal:3100"}`,
|
|
wantErr: "loki: resolve empty.internal: no addresses"},
|
|
{name: "a non-http scheme is refused", content: `{"loki":"ftp://10.0.0.1:3100"}`,
|
|
wantErr: `loki: scheme must be http or https, got "ftp"`},
|
|
{name: "file:// is refused", content: `{"prometheus":"file:///etc/shadow"}`,
|
|
wantErr: "prometheus: scheme must be http or https"},
|
|
// A bare host:port never gets as far as the scheme check — url.Parse
|
|
// rejects it outright — but it is refused, and the message names the key.
|
|
{name: "no scheme is refused", content: `{"loki":"10.0.0.1:3100"}`,
|
|
wantErr: "loki: not a URL"},
|
|
{name: "a bare hostname is refused", content: `{"loki":"10.0.0.1"}`,
|
|
wantErr: "loki: scheme must be http or https"},
|
|
{name: "credentials in the URL are refused", content: `{"loki":"http://u:p@10.0.0.1:3100"}`,
|
|
wantErr: "loki: credentials in the URL"},
|
|
{name: "a path is refused", content: `{"alertmanager":"http://10.0.0.1:9093/api/v2"}`,
|
|
wantErr: "alertmanager: the endpoint is a base URL — no path"},
|
|
{name: "a query is refused", content: `{"loki":"http://10.0.0.1:3100?x=1"}`,
|
|
wantErr: "loki: the endpoint is a base URL — no query"},
|
|
{name: "a bad port is refused", content: `{"loki":"http://10.0.0.1:notaport"}`,
|
|
wantErr: "loki"},
|
|
{name: "no host is refused", content: `{"loki":"http://"}`,
|
|
wantErr: "loki: no host"},
|
|
{name: "malformed JSON is refused", content: `{"loki":`,
|
|
wantErr: "observe-endpoints.json:"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
defer withEndpointsFile(t, c.content)()
|
|
e, err := loadObserveEndpoints(context.Background())
|
|
if c.wantErr == "" {
|
|
if err != nil {
|
|
t.Fatalf("must be accepted, got %v", err)
|
|
}
|
|
if c.check != nil {
|
|
c.check(t, e)
|
|
}
|
|
return
|
|
}
|
|
if err == nil {
|
|
t.Fatalf("must be refused, got %+v", e)
|
|
}
|
|
if !strings.Contains(err.Error(), c.wantErr) {
|
|
t.Fatalf("the message must name the key and the reason\n got: %v\nwant: %q", err, c.wantErr)
|
|
}
|
|
// A refusal returns NOTHING — never a half-loaded file where the
|
|
// keys before the bad one are still live.
|
|
if e.Alertmanager != "" || e.Prometheus != "" || e.Loki != "" {
|
|
t.Fatalf("a refused file must yield no endpoints at all: %+v", e)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// An absent file is UNCONFIGURED, not an error: the same distinction
|
|
// observe:k8s makes about a missing kubeconfig.
|
|
func TestObserveEndpointsAbsentIsUnconfiguredNotAnError(t *testing.T) {
|
|
defer withNoEndpointsFile(t)()
|
|
if _, err := loadObserveEndpoints(context.Background()); !errors.Is(err, errEndpointsAbsent) {
|
|
t.Fatalf("absent must be the sentinel, got %v", err)
|
|
}
|
|
base, unconfigured, err := observeEndpointFor(context.Background(), observeKeyAlertmanager)
|
|
if err != nil || base != "" || !strings.HasPrefix(unconfigured, "unconfigured: no endpoints file") {
|
|
t.Fatalf("base=%q unconfigured=%q err=%v", base, unconfigured, err)
|
|
}
|
|
// A file that exists but names a different service: still unconfigured for
|
|
// this key, and the message says WHICH key is missing.
|
|
defer withEndpointsFile(t, `{"loki":"http://127.0.0.1:3100"}`)()
|
|
_, unconfigured, err = observeEndpointFor(context.Background(), observeKeyAlertmanager)
|
|
if err != nil || !strings.Contains(unconfigured, `names no "alertmanager"`) {
|
|
t.Fatalf("unconfigured=%q err=%v", unconfigured, err)
|
|
}
|
|
}
|
|
|
|
// ── fixtures ─────────────────────────────────────────────────────────────────
|
|
|
|
// fxAlerts is three active alerts of mixed severity, in the shape Alertmanager
|
|
// v2 actually returns (confirmed against a live Alertmanager). Deliberately
|
|
// NOT in severity order on the wire, so the ordering under test is this box's
|
|
// and not the server's.
|
|
const fxAlerts = `[
|
|
{"fingerprint":"aaa1","labels":{"alertname":"KubeMemoryOvercommit","severity":"warning","cluster":"platform"},
|
|
"startsAt":"2026-09-14T08:12:16.597Z","status":{"state":"active"}},
|
|
{"fingerprint":"bbb2","labels":{"alertname":"FleetUnitFailed","severity":"critical","unit":"edge-checker.service"},
|
|
"startsAt":"2026-09-15T01:00:00.000Z","status":{"state":"active"}},
|
|
{"fingerprint":"ccc3","labels":{"alertname":"Watchdog","severity":"none"},
|
|
"startsAt":"2026-09-16T02:00:00.000Z","status":{"state":"active"}}
|
|
]`
|
|
|
|
// fakeMonitoring serves the three upstreams on one loopback httptest server and
|
|
// writes an endpoints file naming it. Loopback is allowed here by design, which
|
|
// is what makes an end-to-end test of these ops possible at all.
|
|
func fakeMonitoring(t *testing.T, h http.HandlerFunc) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(h)
|
|
t.Cleanup(srv.Close)
|
|
restore := withEndpointsFile(t, fmt.Sprintf(
|
|
`{"alertmanager":%q,"prometheus":%q,"loki":%q}`, srv.URL, srv.URL, srv.URL))
|
|
t.Cleanup(restore)
|
|
return srv
|
|
}
|
|
|
|
// ── observe:alerts ───────────────────────────────────────────────────────────
|
|
|
|
func TestObserveAlertsEndToEnd(t *testing.T) {
|
|
var asked string
|
|
fakeMonitoring(t, func(w http.ResponseWriter, r *http.Request) {
|
|
asked = r.URL.RequestURI()
|
|
_, _ = w.Write([]byte(fxAlerts))
|
|
})
|
|
out, errOut, code := driveObserve(t, "observe:alerts", nil)
|
|
if code != 0 {
|
|
t.Fatalf("exit %d %s", code, errOut)
|
|
}
|
|
if asked != alertmanagerAlertsPath {
|
|
t.Fatalf("the op must ask the v2 path verbatim: %q", asked)
|
|
}
|
|
// All four v2 filter flags are named — see
|
|
// TestObserveAlertsExcludesSilencedAndInhibited for why active=true alone
|
|
// is not a narrowing.
|
|
for _, want := range []string{"active=true", "silenced=false", "inhibited=false", "unprocessed=false"} {
|
|
if !strings.Contains(asked, want) {
|
|
t.Fatalf("missing %q from %q", want, asked)
|
|
}
|
|
}
|
|
var rep alertsReport
|
|
if err := json.Unmarshal([]byte(out), &rep); err != nil {
|
|
t.Fatalf("%v: %s", err, out)
|
|
}
|
|
if !rep.Supported || rep.Source != "alertmanager" || rep.Count != 3 || len(rep.Alerts) != 3 || rep.Truncated {
|
|
t.Fatalf("%+v", rep)
|
|
}
|
|
byFP := map[string]observedAlert{}
|
|
for _, a := range rep.Alerts {
|
|
byFP[a.Fingerprint] = a
|
|
}
|
|
if a := byFP["bbb2"]; a.Alertname != "FleetUnitFailed" || a.Severity != "critical" ||
|
|
a.State != "active" || a.StartsAt != "2026-09-15T01:00:00.000Z" ||
|
|
a.Labels["unit"] != "edge-checker.service" {
|
|
t.Fatalf("the typed row must carry the labels through: %+v", a)
|
|
}
|
|
}
|
|
|
|
// The summary the heartbeat carries: counts by severity, the OLDEST start, and
|
|
// the fingerprints critical first.
|
|
func TestAlertsSummaryCountsAndOrder(t *testing.T) {
|
|
rep, err := parseAlerts([]byte(fxAlerts))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Date(2026, 9, 16, 3, 0, 0, 0, time.UTC)
|
|
s := summarizeAlerts(rep, now)
|
|
if s.Firing != 3 {
|
|
t.Fatalf("firing counts EVERY active alert, whatever its severity: %+v", s)
|
|
}
|
|
if s.Critical != 1 || s.Warning != 1 {
|
|
t.Fatalf("severity buckets: %+v", s)
|
|
}
|
|
if s.Since == nil || *s.Since != "2026-09-14T08:12:16Z" {
|
|
t.Fatalf("since is the OLDEST startsAt: %v", s.Since)
|
|
}
|
|
if s.At != "2026-09-16T03:00:00Z" {
|
|
t.Fatalf("at is this refresh: %q", s.At)
|
|
}
|
|
// critical, then warning, then the rest — never the server's order.
|
|
if got := strings.Join(s.Fingerprints, ","); got != "bbb2,aaa1,ccc3" {
|
|
t.Fatalf("fingerprints must be critical first, got %q", got)
|
|
}
|
|
// An empty Alertmanager summarizes to zeros with a null `since` — a real
|
|
// state, distinct from the key being absent.
|
|
empty := summarizeAlerts(alertsReport{Alerts: []observedAlert{}}, now)
|
|
if empty.Firing != 0 || empty.Since != nil || len(empty.Fingerprints) != 0 {
|
|
t.Fatalf("%+v", empty)
|
|
}
|
|
b, _ := json.Marshal(empty)
|
|
if !strings.Contains(string(b), `"since":null`) {
|
|
t.Fatalf("since must serialize as null, not an empty string: %s", b)
|
|
}
|
|
}
|
|
|
|
// Both caps, and both reported.
|
|
func TestObserveAlertsTruncation(t *testing.T) {
|
|
big := strings.Repeat("x", observeLabelValueCap+1)
|
|
var rows []string
|
|
for i := 0; i < observeAlertCap+50; i++ {
|
|
rows = append(rows, fmt.Sprintf(
|
|
`{"fingerprint":"fp%03d","labels":{"alertname":"A","severity":"critical","blob":%q},`+
|
|
`"startsAt":"2026-09-15T01:00:00Z","status":{"state":"active"}}`, i, big))
|
|
}
|
|
rep, err := parseAlerts([]byte("[" + strings.Join(rows, ",") + "]"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rep.Count != observeAlertCap || len(rep.Alerts) != observeAlertCap {
|
|
t.Fatalf("the alert cap must hold: %d", rep.Count)
|
|
}
|
|
if !rep.Truncated {
|
|
t.Fatal("a cut must be reported, never silent")
|
|
}
|
|
if _, present := rep.Alerts[0].Labels["blob"]; present {
|
|
t.Fatalf("a label value over %d bytes must be dropped: %+v", observeLabelValueCap, rep.Alerts[0].Labels)
|
|
}
|
|
if len(rep.Alerts[0].DroppedLabels) != 1 || rep.Alerts[0].DroppedLabels[0] != "blob" {
|
|
t.Fatalf("a dropped label must be NAMED, or absent reads as unset: %+v", rep.Alerts[0].DroppedLabels)
|
|
}
|
|
if rep.Alerts[0].Labels["alertname"] != "A" || rep.Alerts[0].Severity != "critical" {
|
|
t.Fatalf("the labels under the cap ride through: %+v", rep.Alerts[0])
|
|
}
|
|
// The summary never carries more than twenty fingerprints — and `firing`
|
|
// is the REAL number, not the row cap. See
|
|
// TestAlertCountsAreNotCappedWithTheRows for why that distinction matters.
|
|
s := summarizeAlerts(rep, time.Now())
|
|
if len(s.Fingerprints) != alertsFingerprintCap {
|
|
t.Fatalf("fingerprints=%d", len(s.Fingerprints))
|
|
}
|
|
if s.Firing != observeAlertCap+50 || rep.ActiveTotal != observeAlertCap+50 {
|
|
t.Fatalf("firing must be the true count, not the row cap: firing=%d active_total=%d",
|
|
s.Firing, rep.ActiveTotal)
|
|
}
|
|
}
|
|
|
|
// ── observe:metrics ──────────────────────────────────────────────────────────
|
|
|
|
func TestObserveMetricsFixedTable(t *testing.T) {
|
|
var asked string
|
|
fakeMonitoring(t, func(w http.ResponseWriter, r *http.Request) {
|
|
asked = r.URL.Query().Get("query")
|
|
_, _ = w.Write([]byte(`{"status":"success","data":{"resultType":"vector","result":[
|
|
{"metric":{"__name__":"up","job":"kubelet","node":"example-host"},"value":[1789525594.139,"1"]},
|
|
{"metric":{"__name__":"up","job":"kubelet","node":"example-host-2"},"value":[1789525594.139,"0"]}]}}`))
|
|
})
|
|
out, errOut, code := driveObserve(t, "observe:metrics", []string{"up"})
|
|
if code != 0 {
|
|
t.Fatalf("exit %d %s", code, errOut)
|
|
}
|
|
if asked != "up" {
|
|
t.Fatalf("the box owns the PromQL: %q", asked)
|
|
}
|
|
var rep metricsReport
|
|
if err := json.Unmarshal([]byte(out), &rep); err != nil {
|
|
t.Fatalf("%v: %s", err, out)
|
|
}
|
|
if !rep.Supported || rep.Name != "up" || rep.Query != "up" || rep.Count != 2 {
|
|
t.Fatalf("%+v", rep)
|
|
}
|
|
if rep.Series[1].Value != "0" || rep.Series[1].Labels["node"] != "example-host-2" || rep.Series[1].TS == 0 {
|
|
t.Fatalf("%+v", rep.Series[1])
|
|
}
|
|
|
|
// Every name in the table reaches the wire as the box's own query, and the
|
|
// query that goes out is never the caller's bytes.
|
|
for _, name := range metricNames() {
|
|
if _, _, code := driveObserve(t, "observe:metrics", []string{name}); code != 0 {
|
|
t.Fatalf("%s: exit %d", name, code)
|
|
}
|
|
if asked != observeMetricQueries[name] {
|
|
t.Fatalf("%s sent %q, want %q", name, asked, observeMetricQueries[name])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestObserveMetricsUnknownNameIsRefusedWithTheKnownOnes(t *testing.T) {
|
|
defer withNoEndpointsFile(t)()
|
|
// The refusal happens BEFORE any endpoint is consulted, so it works on a
|
|
// box with no endpoints file at all — and nothing leaves the process.
|
|
orig := observeHTTPGet
|
|
observeHTTPGet = func(_ context.Context, rawURL string) ([]byte, int, error) {
|
|
t.Fatalf("an unknown name reached the network: %q", rawURL)
|
|
return nil, 0, nil
|
|
}
|
|
defer func() { observeHTTPGet = orig }()
|
|
|
|
for _, bad := range []string{"node_cpu; rm -rf /", "sum(rate(x[5m]))", "", "UP", "up "} {
|
|
out, errOut, code := driveObserve(t, "observe:metrics", []string{bad})
|
|
if code == 0 {
|
|
t.Fatalf("%q was ACCEPTED — the table is the wall: %s", bad, out)
|
|
}
|
|
if !strings.Contains(errOut, "unknown metric name") {
|
|
t.Fatalf("%q: %q", bad, errOut)
|
|
}
|
|
for _, name := range metricNames() {
|
|
if !strings.Contains(errOut, name) {
|
|
t.Fatalf("the refusal must list every known name; %q missing from %q", name, errOut)
|
|
}
|
|
}
|
|
}
|
|
// Wrong arity refuses too, and still names the table.
|
|
for _, args := range [][]string{nil, {"up", "extra"}} {
|
|
_, errOut, code := driveObserve(t, "observe:metrics", args)
|
|
if code == 0 || !strings.Contains(errOut, "exactly one argument") {
|
|
t.Fatalf("args=%v code=%d err=%q", args, code, errOut)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A Prometheus that rejects the query is an ERROR, never an empty series list:
|
|
// "no series" and "the query was refused" may not read the same.
|
|
func TestObserveMetricsRejectedQueryIsNotAnEmptyResult(t *testing.T) {
|
|
fakeMonitoring(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte(`{"status":"error","errorType":"bad_data","error":"parse error"}`))
|
|
})
|
|
_, errOut, code := driveObserve(t, "observe:metrics", []string{"up"})
|
|
if code == 0 || !strings.Contains(errOut, "HTTP 400") {
|
|
t.Fatalf("code=%d err=%q", code, errOut)
|
|
}
|
|
}
|
|
|
|
// ── observe:logs ─────────────────────────────────────────────────────────────
|
|
|
|
func TestObserveLogsEndToEndNewestLast(t *testing.T) {
|
|
var q url.Values
|
|
fakeMonitoring(t, func(w http.ResponseWriter, r *http.Request) {
|
|
q = r.URL.Query()
|
|
// Two streams, interleaved and out of order on the wire.
|
|
_, _ = w.Write([]byte(`{"status":"success","data":{"resultType":"streams","result":[
|
|
{"stream":{"unit":""},"values":[["300","third"],["100","first"]]},
|
|
{"stream":{"unit":""},"values":[["200","second"]]}]}}`))
|
|
})
|
|
out, errOut, code := driveObserve(t, "observe:logs", []string{"kernel"})
|
|
if code != 0 {
|
|
t.Fatalf("exit %d %s", code, errOut)
|
|
}
|
|
if got := q.Get("query"); got != mustSelector(t, "kernel") {
|
|
t.Fatalf("the box owns the LogQL: %q", got)
|
|
}
|
|
if q.Get("direction") != "backward" {
|
|
t.Fatalf("Loki is asked backward so a server-side limit keeps the NEWEST: %q", q.Get("direction"))
|
|
}
|
|
var rep logsReport
|
|
if err := json.Unmarshal([]byte(out), &rep); err != nil {
|
|
t.Fatalf("%v: %s", err, out)
|
|
}
|
|
if rep.Minutes != observeLogMinutesDefault || rep.Name != "kernel" || rep.Lines != 3 || rep.Truncated {
|
|
t.Fatalf("%+v", rep)
|
|
}
|
|
lines := strings.Split(strings.TrimRight(rep.Log, "\n"), "\n")
|
|
if !strings.HasSuffix(lines[0], "first") || !strings.HasSuffix(lines[2], "third") {
|
|
t.Fatalf("newest last, across streams: %q", rep.Log)
|
|
}
|
|
}
|
|
|
|
func TestObserveLogsMinutes(t *testing.T) {
|
|
var q url.Values
|
|
fakeMonitoring(t, func(w http.ResponseWriter, r *http.Request) {
|
|
q = r.URL.Query()
|
|
_, _ = w.Write([]byte(`{"status":"success","data":{"resultType":"streams","result":[]}}`))
|
|
})
|
|
// Accepted: the whole 1..60 boundary, and the window actually asked for.
|
|
for _, m := range []string{"1", "15", "60"} {
|
|
out, errOut, code := driveObserve(t, "observe:logs", []string{"sshd", m})
|
|
if code != 0 {
|
|
t.Fatalf("%s minutes: exit %d %s", m, code, errOut)
|
|
}
|
|
var rep logsReport
|
|
if err := json.Unmarshal([]byte(out), &rep); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
start, end := mustNano(t, q.Get("start")), mustNano(t, q.Get("end"))
|
|
want := time.Duration(rep.Minutes) * time.Minute
|
|
if got := time.Duration(end - start); got != want {
|
|
t.Fatalf("%s minutes: asked Loki for %v, want %v", m, got, want)
|
|
}
|
|
}
|
|
// Refused, never CLAMPED: a host that asked for 600 minutes and silently
|
|
// got 60 would draw a ten-hour conclusion from one hour of logs.
|
|
for _, m := range []string{"0", "61", "600", "-5", "abc", "15.5", ""} {
|
|
_, errOut, code := driveObserve(t, "observe:logs", []string{"sshd", m})
|
|
if code == 0 {
|
|
t.Fatalf("%q minutes was accepted", m)
|
|
}
|
|
if !strings.Contains(errOut, "minutes must be") {
|
|
t.Fatalf("%q: %q", m, errOut)
|
|
}
|
|
}
|
|
// Unknown name: refused with the known ones listed, same as metrics.
|
|
_, errOut, code := driveObserve(t, "observe:logs", []string{`{job="everything"}`})
|
|
if code == 0 || !strings.Contains(errOut, "unknown log name") {
|
|
t.Fatalf("code=%d err=%q", code, errOut)
|
|
}
|
|
for _, name := range logNames() {
|
|
if !strings.Contains(errOut, name) {
|
|
t.Fatalf("%q missing from the refusal %q", name, errOut)
|
|
}
|
|
}
|
|
}
|
|
|
|
func mustNano(t *testing.T, s string) int64 {
|
|
t.Helper()
|
|
var n int64
|
|
if _, err := fmt.Sscanf(s, "%d", &n); err != nil {
|
|
t.Fatalf("bad nano %q: %v", s, err)
|
|
}
|
|
return n
|
|
}
|
|
|
|
// 200 KB, the same cap `ufw status verbose` is held to — and the OLDEST lines
|
|
// are the ones that go, because the answer ends on the newest.
|
|
func TestObserveLogsByteCap(t *testing.T) {
|
|
line := strings.Repeat("y", 1024)
|
|
var vals []string
|
|
for i := 0; i < 400; i++ { // ~400 KB, twice the cap
|
|
vals = append(vals, fmt.Sprintf(`["%d","%04d %s"]`, (i+1)*1000, i, line))
|
|
}
|
|
fakeMonitoring(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`{"status":"success","data":{"resultType":"streams","result":[
|
|
{"stream":{},"values":[` + strings.Join(vals, ",") + `]}]}}`))
|
|
})
|
|
out, errOut, code := driveObserve(t, "observe:logs", []string{"bastion"})
|
|
if code != 0 {
|
|
t.Fatalf("exit %d %s", code, errOut)
|
|
}
|
|
var rep logsReport
|
|
if err := json.Unmarshal([]byte(out), &rep); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rep.Log) > observeTextCap {
|
|
t.Fatalf("log is %d bytes, cap is %d", len(rep.Log), observeTextCap)
|
|
}
|
|
if !rep.Truncated {
|
|
t.Fatal("a cut must be reported")
|
|
}
|
|
if !strings.HasSuffix(strings.TrimRight(rep.Log, "\n"), "0399 "+line) {
|
|
t.Fatalf("the NEWEST line must survive the cut; log ends %q", rep.Log[len(rep.Log)-40:])
|
|
}
|
|
if strings.Contains(rep.Log, "0000 ") {
|
|
t.Fatal("the oldest line should have been the one dropped")
|
|
}
|
|
}
|
|
|
|
// ── unconfigured, and the wall ───────────────────────────────────────────────
|
|
|
|
// Absent file: all three report the observe:k8s shape at the observe:k8s exit
|
|
// code, and none of them touches the network.
|
|
func TestObservePullUnconfiguredShape(t *testing.T) {
|
|
defer withNoEndpointsFile(t)()
|
|
orig := observeHTTPGet
|
|
observeHTTPGet = func(_ context.Context, rawURL string) ([]byte, int, error) {
|
|
t.Fatalf("an unconfigured op dialled %q", rawURL)
|
|
return nil, 0, nil
|
|
}
|
|
defer func() { observeHTTPGet = orig }()
|
|
|
|
for op, args := range map[string][]string{
|
|
"observe:alerts": nil,
|
|
"observe:metrics": {"up"},
|
|
"observe:logs": {"kernel"},
|
|
} {
|
|
out, errOut, code := driveObserve(t, op, args)
|
|
if code != 0 {
|
|
t.Fatalf("%s: unconfigured is a REPORT, not a refusal — observe:k8s exits 0: %d %q", op, code, errOut)
|
|
}
|
|
var rep struct {
|
|
Supported bool `json:"supported"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
if err := json.Unmarshal([]byte(out), &rep); err != nil {
|
|
t.Fatalf("%s: %v: %s", op, err, out)
|
|
}
|
|
if rep.Supported {
|
|
t.Fatalf("%s: %s", op, out)
|
|
}
|
|
if !strings.Contains(rep.Reason, "unconfigured") || !strings.Contains(rep.Reason, observeEndpointsPath) {
|
|
t.Fatalf("%s: the reason must name the file the admin has to place: %q", op, rep.Reason)
|
|
}
|
|
}
|
|
// A file that is there and WRONG is a different answer: a refusal that
|
|
// names the key, so the admin knows which line to fix.
|
|
defer withEndpointsFile(t, `{"prometheus":"http://8.8.8.8:9090"}`)()
|
|
_, errOut, code := driveObserve(t, "observe:metrics", []string{"up"})
|
|
if code != 2 || !strings.Contains(errOut, "prometheus:") || !strings.Contains(errOut, "public address") {
|
|
t.Fatalf("code=%d err=%q", code, errOut)
|
|
}
|
|
}
|
|
|
|
// ── the heartbeat contract ───────────────────────────────────────────────────
|
|
|
|
// ABSENT is not ZERO. The key is missing until a refresh has succeeded, and a
|
|
// zero summary — a real all-clear — is a different thing on the wire.
|
|
func TestHeartbeatAlertsAbsentIsNotZero(t *testing.T) {
|
|
alertsMu.Lock()
|
|
alertsLast = nil
|
|
alertsMu.Unlock()
|
|
defer func() {
|
|
alertsMu.Lock()
|
|
alertsLast = nil
|
|
alertsMu.Unlock()
|
|
}()
|
|
defer withNoEndpointsFile(t)()
|
|
|
|
raw := buildHeartbeatBody("", "box", "linux", "test", "pub", "fp", "", "", false, nil, nil, nil)
|
|
var m map[string]any
|
|
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, present := m["alerts"]; present {
|
|
t.Fatalf("with no alertmanager configured the key must be ABSENT: %s", raw)
|
|
}
|
|
// A refresh against a box with no endpoints file publishes nothing at all.
|
|
refreshAlerts(context.Background())
|
|
if alertsPayload() != nil {
|
|
t.Fatal("an unconfigured refresh must publish nothing")
|
|
}
|
|
}
|
|
|
|
// The exact shape the host side is built against, served from the cache — a
|
|
// beat never waits on the fetch.
|
|
func TestHeartbeatAlertsContract(t *testing.T) {
|
|
calls := 0
|
|
fakeMonitoring(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
calls++
|
|
_, _ = w.Write([]byte(fxAlerts))
|
|
})
|
|
alertsMu.Lock()
|
|
alertsLast = nil
|
|
alertsMu.Unlock()
|
|
defer func() {
|
|
alertsMu.Lock()
|
|
alertsLast = nil
|
|
alertsMu.Unlock()
|
|
}()
|
|
|
|
raw := buildHeartbeatBody("", "box", "linux", "test", "pub", "fp", "", "", false, nil, nil, nil)
|
|
if strings.Contains(raw, `"alerts"`) || calls != 0 {
|
|
t.Fatalf("before the first refresh: no key, and no fetch on the beat (calls=%d)", calls)
|
|
}
|
|
refreshAlerts(context.Background()) // what the background loop does once a minute
|
|
if calls != 1 {
|
|
t.Fatalf("one fetch per refresh, got %d", calls)
|
|
}
|
|
|
|
raw = buildHeartbeatBody("", "box", "linux", "test", "pub", "fp", "", "", false, nil, nil, nil)
|
|
_ = buildHeartbeatBody("", "box", "linux", "test", "pub", "fp", "", "", false, nil, nil, nil)
|
|
if calls != 1 {
|
|
t.Fatalf("a beat must SERVE the last value, never fetch: %d fetches", calls)
|
|
}
|
|
var body struct {
|
|
Alerts map[string]any `json:"alerts"`
|
|
}
|
|
if err := json.Unmarshal([]byte(raw), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, k := range []string{"firing", "critical", "warning", "since", "at", "fingerprints"} {
|
|
if _, ok := body.Alerts[k]; !ok {
|
|
t.Fatalf("the contract key %q is missing: %v", k, body.Alerts)
|
|
}
|
|
}
|
|
if len(body.Alerts) != 6 {
|
|
t.Fatalf("the contract is exactly six keys; extras break the host side: %v", body.Alerts)
|
|
}
|
|
if body.Alerts["firing"].(float64) != 3 || body.Alerts["critical"].(float64) != 1 ||
|
|
body.Alerts["warning"].(float64) != 1 {
|
|
t.Fatalf("%v", body.Alerts)
|
|
}
|
|
if body.Alerts["since"].(string) != "2026-09-14T08:12:16Z" {
|
|
t.Fatalf("since: %v", body.Alerts["since"])
|
|
}
|
|
if _, err := time.Parse(time.RFC3339, body.Alerts["at"].(string)); err != nil {
|
|
t.Fatalf("at must be RFC3339: %v", body.Alerts["at"])
|
|
}
|
|
fps := body.Alerts["fingerprints"].([]any)
|
|
if len(fps) != 3 || fps[0].(string) != "bbb2" {
|
|
t.Fatalf("critical first: %v", fps)
|
|
}
|
|
|
|
// A refresh that FAILS keeps the last good value rather than publishing an
|
|
// all-clear the box cannot stand behind.
|
|
before := *alertsPayload()
|
|
origGet := observeHTTPGet
|
|
observeHTTPGet = func(context.Context, string) ([]byte, int, error) {
|
|
return nil, 0, errors.New("connection refused")
|
|
}
|
|
refreshAlerts(context.Background())
|
|
observeHTTPGet = origGet
|
|
after := alertsPayload()
|
|
if after == nil || after.At != before.At || after.Firing != before.Firing {
|
|
t.Fatalf("a failed refresh must not overwrite the last good summary: %+v -> %+v", before, after)
|
|
}
|
|
}
|
|
|
|
// mustSelector is the selector the op would actually build for a name on this
|
|
// box, so a test never hardcodes a string the product no longer produces.
|
|
func mustSelector(t *testing.T, name string) string {
|
|
t.Helper()
|
|
s, _, ok := observeLogSelector(name)
|
|
if !ok {
|
|
t.Fatalf("no selector for %q", name)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// ── the wall at the socket: redirects and the dial control ───────────────────
|
|
|
|
// THE BLOCKING ONE. A configured Alertmanager answering 302 must not walk the
|
|
// box to the redirect target. Go follows up to ten redirects by default and the
|
|
// endpoints-file check only ever saw the CONFIGURED address, so this is the
|
|
// path by which an unattended 60 s loop would have made outbound GETs from
|
|
// inside the perimeter with the box's own identity.
|
|
func TestObserveNeverFollowsARedirect(t *testing.T) {
|
|
reached := make(chan string, 8)
|
|
off := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
reached <- r.URL.RequestURI()
|
|
_, _ = w.Write([]byte(`[]`))
|
|
}))
|
|
defer off.Close()
|
|
|
|
for _, code := range []int{301, 302, 303, 307, 308} {
|
|
t.Run(strconv.Itoa(code), func(t *testing.T) {
|
|
fakeMonitoring(t, func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, off.URL+"/api/v2/alerts?active=true", code)
|
|
})
|
|
out, errOut, exit := driveObserve(t, "observe:alerts", nil)
|
|
select {
|
|
case got := <-reached:
|
|
t.Fatalf("THE WALL IS OPEN: the redirect target was fetched (%q). "+
|
|
"out=%s", got, out)
|
|
default:
|
|
}
|
|
// The 30x is a RESULT and is reported as the failure it is.
|
|
if exit == 0 {
|
|
t.Fatalf("a %d must be reported as a failure, not answered: %s", code, out)
|
|
}
|
|
if !strings.Contains(errOut, "HTTP "+strconv.Itoa(code)) {
|
|
t.Fatalf("the refusal must name the status: %q", errOut)
|
|
}
|
|
})
|
|
}
|
|
|
|
// And the background refresh — the unattended path — publishes nothing
|
|
// rather than following it.
|
|
fakeMonitoring(t, func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, off.URL+"/api/v2/alerts", http.StatusFound)
|
|
})
|
|
alertsMu.Lock()
|
|
alertsLast = nil
|
|
alertsMu.Unlock()
|
|
defer func() {
|
|
alertsMu.Lock()
|
|
alertsLast = nil
|
|
alertsMu.Unlock()
|
|
}()
|
|
refreshAlerts(context.Background())
|
|
select {
|
|
case got := <-reached:
|
|
t.Fatalf("the 60 s refresh loop followed a redirect to %q", got)
|
|
default:
|
|
}
|
|
if alertsPayload() != nil {
|
|
t.Fatal("a redirected refresh must publish nothing")
|
|
}
|
|
}
|
|
|
|
// The socket-level wall, which is what closes the rebinding shape: whatever the
|
|
// resolver says at connect time, a public address is refused before connect.
|
|
func TestObserveDialControlRefusesPublicAtConnectTime(t *testing.T) {
|
|
for _, addr := range []string{"8.8.8.8:443", "203.0.113.10:80", "[2606:4700::1111]:443"} {
|
|
if err := observeDialControl("tcp", addr, nil); err == nil {
|
|
t.Errorf("%s was allowed at the socket — rebinding is open", addr)
|
|
} else if !strings.Contains(err.Error(), errObserveDialPublic) {
|
|
t.Errorf("%s: %v", addr, err)
|
|
}
|
|
}
|
|
// The box's own neighbourhood still connects — the inverse, at the socket.
|
|
for _, addr := range []string{"127.0.0.1:9093", "10.99.1.1:9093", "[fd00:1::10]:3100",
|
|
"169.254.1.1:80", "100.64.0.1:3100"} {
|
|
if err := observeDialControl("tcp", addr, nil); err != nil {
|
|
t.Errorf("%s must be allowed: %v", addr, err)
|
|
}
|
|
}
|
|
// A name that reached the dialer unresolved is refused rather than dialed.
|
|
if err := observeDialControl("tcp", "evil.example:80", nil); err == nil {
|
|
t.Error("an unresolved host at connect time must be refused")
|
|
}
|
|
|
|
// End to end through the real client: nothing leaves the box, because the
|
|
// refusal happens before connect.
|
|
_, _, err := observeHTTPGet(context.Background(), "http://8.8.8.8:80/api/v2/alerts")
|
|
if err == nil || !strings.Contains(err.Error(), errObserveDialPublic) {
|
|
t.Fatalf("the transport must carry the dial control: %v", err)
|
|
}
|
|
}
|
|
|
|
// The body cap is implemented AND exercised: removing the LimitReader must
|
|
// fail a test, which it did not before.
|
|
func TestObserveBodyCapIsEnforced(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
chunk := strings.Repeat("z", 64*1024)
|
|
for i := 0; i < 64; i++ { // 4 MiB, four times the cap
|
|
if _, err := w.Write([]byte(chunk)); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
// An OVERSIZED body is an error that SAYS so. Handing back a clean 1 MiB of
|
|
// cut bytes surfaced three frames later as "unexpected end of JSON input",
|
|
// which blames the endpoint for something the reader did.
|
|
if _, _, err := observeHTTPGet(context.Background(), srv.URL); err == nil {
|
|
t.Fatal("a body past the cap must be an error, not a quiet truncation")
|
|
} else if !strings.Contains(err.Error(), "response too large") {
|
|
t.Fatalf("the error must name the reason: %v", err)
|
|
}
|
|
|
|
// A body at EXACTLY the cap is fine and comes back whole. That boundary is
|
|
// the case a cap-sized read could not distinguish from a cut one at all,
|
|
// which is why the read is cap+1.
|
|
exact := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(strings.Repeat("z", observeBodyCap)))
|
|
}))
|
|
defer exact.Close()
|
|
body, code, err := observeHTTPGet(context.Background(), exact.URL)
|
|
if err != nil {
|
|
t.Fatalf("exactly at the cap must succeed: %v", err)
|
|
}
|
|
if code != 200 || len(body) != observeBodyCap {
|
|
t.Fatalf("code=%d len=%d", code, len(body))
|
|
}
|
|
|
|
// End to end: the op refuses naming the size, never a parser error.
|
|
fakeMonitoring(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
chunk := strings.Repeat("z", 64*1024)
|
|
for i := 0; i < 32; i++ {
|
|
if _, err := w.Write([]byte(chunk)); err != nil {
|
|
return
|
|
}
|
|
}
|
|
})
|
|
_, errOut, exit := driveObserve(t, "observe:alerts", nil)
|
|
if exit == 0 || !strings.Contains(errOut, "response too large") {
|
|
t.Fatalf("exit=%d err=%q", exit, errOut)
|
|
}
|
|
if strings.Contains(errOut, "unexpected end of JSON") {
|
|
t.Fatalf("a size problem must not surface as a parser error: %q", errOut)
|
|
}
|
|
}
|
|
|
|
// ── silenced and inhibited alerts never page ─────────────────────────────────
|
|
|
|
// In Alertmanager v2 active/silenced/inhibited/unprocessed each default TRUE,
|
|
// so `?active=true` alone narrows nothing. Both halves are checked: the query
|
|
// string asks for the narrowing, and the box's own arithmetic refuses to count
|
|
// a non-active alert that arrives anyway.
|
|
func TestObserveAlertsExcludesSilencedAndInhibited(t *testing.T) {
|
|
const mixed = `[
|
|
{"fingerprint":"act1","labels":{"alertname":"Real","severity":"critical"},
|
|
"startsAt":"2026-09-15T01:00:00Z","status":{"state":"active"}},
|
|
{"fingerprint":"sil2","labels":{"alertname":"Silenced","severity":"critical"},
|
|
"startsAt":"2026-09-10T01:00:00Z","status":{"state":"suppressed","silencedBy":["abc"]}},
|
|
{"fingerprint":"inh3","labels":{"alertname":"Inhibited","severity":"warning"},
|
|
"startsAt":"2026-09-11T01:00:00Z","status":{"state":"suppressed","inhibitedBy":["def"]}}
|
|
]`
|
|
var asked string
|
|
fakeMonitoring(t, func(w http.ResponseWriter, r *http.Request) {
|
|
asked = r.URL.RawQuery
|
|
_, _ = w.Write([]byte(mixed))
|
|
})
|
|
if _, errOut, code := driveObserve(t, "observe:alerts", nil); code != 0 {
|
|
t.Fatalf("exit %d %s", code, errOut)
|
|
}
|
|
for _, want := range []string{"active=true", "silenced=false", "inhibited=false"} {
|
|
if !strings.Contains(asked, want) {
|
|
t.Fatalf("the query must ask for %q — active=true alone narrows nothing: %q", want, asked)
|
|
}
|
|
}
|
|
|
|
// The second half: a suppressed alert that arrives anyway is not firing.
|
|
rep, err := parseAlerts([]byte(mixed))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := summarizeAlerts(rep, time.Now())
|
|
if s.Firing != 1 || s.Critical != 1 || s.Warning != 0 {
|
|
t.Fatalf("a silence is an operator saying stop telling me; only the active alert fires: %+v", s)
|
|
}
|
|
if len(s.Fingerprints) != 1 || s.Fingerprints[0] != "act1" {
|
|
t.Fatalf("fingerprints: %v", s.Fingerprints)
|
|
}
|
|
// `since` is the oldest ACTIVE start — never the older silenced one.
|
|
if s.Since == nil || *s.Since != "2026-09-15T01:00:00Z" {
|
|
t.Fatalf("since must ignore suppressed alerts: %v", s.Since)
|
|
}
|
|
}
|
|
|
|
// ── the refresh runs under the lifecycle context ─────────────────────────────
|
|
|
|
func TestAlertsRefreshHonoursTheLifecycleContext(t *testing.T) {
|
|
entered := make(chan struct{})
|
|
orig := observeHTTPGet
|
|
observeHTTPGet = func(ctx context.Context, _ string) ([]byte, int, error) {
|
|
close(entered)
|
|
<-ctx.Done() // a server that accepts and never answers
|
|
return nil, 0, ctx.Err()
|
|
}
|
|
defer func() { observeHTTPGet = orig }()
|
|
fakeMonitoring(t, func(w http.ResponseWriter, _ *http.Request) {})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan struct{})
|
|
go func() { refreshAlerts(ctx); close(done) }()
|
|
<-entered
|
|
cancel()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
// The 20 s op budget would outlive a shutdown if the fetch were rooted
|
|
// at context.Background() instead of the heartbeat's own context.
|
|
t.Fatal("cancelling the lifecycle context must end an in-flight fetch")
|
|
}
|
|
if alertsPayload() != nil {
|
|
t.Fatal("a cancelled refresh must publish nothing")
|
|
}
|
|
}
|
|
|
|
// ── scope: this box, and saying so when it cannot be ─────────────────────────
|
|
|
|
// The three journal names are pinned to THIS box; k8s-system cannot be, because
|
|
// pod streams carry no `node` label at all — every kube-system stream is
|
|
// {cluster,container,instance,job,namespace,pod,service_name}, and
|
|
// {namespace="kube-system",node="example-host"} returns zero lines. The
|
|
// report states which, so nobody infers scope from the name.
|
|
func TestObserveLogsScopedToThisBox(t *testing.T) {
|
|
orig := observeHostname
|
|
observeHostname = func() (string, error) { return "example-host", nil }
|
|
defer func() { observeHostname = orig }()
|
|
|
|
for _, name := range []string{"kernel", "sshd", "bastion"} {
|
|
sel, scope, ok := observeLogSelector(name)
|
|
if !ok {
|
|
t.Fatalf("%s: no selector", name)
|
|
}
|
|
if !strings.Contains(sel, `node="example-host"`) {
|
|
t.Fatalf("%s must be scoped to this box, got %q", name, sel)
|
|
}
|
|
if scope != observeScopeNode {
|
|
t.Fatalf("%s scope %q", name, scope)
|
|
}
|
|
}
|
|
// The one that cannot be scoped says cluster, and carries no node matcher
|
|
// — a matcher on a label that does not exist returns nothing at all.
|
|
sel, scope, _ := observeLogSelector("k8s-system")
|
|
if strings.Contains(sel, "node=") || scope != observeScopeCluster {
|
|
t.Fatalf("k8s-system: %q %q", sel, scope)
|
|
}
|
|
// The bastion's own unit, by the name install.sh writes.
|
|
if s, _, _ := observeLogSelector("bastion"); !strings.Contains(s, `unit="privatae-bastion.service"`) {
|
|
t.Fatalf("bastion selector: %q", s)
|
|
}
|
|
|
|
// A box that cannot read its hostname answers cluster-wide and SAYS so,
|
|
// rather than silently presenting the estate as this machine.
|
|
observeHostname = func() (string, error) { return "", errors.New("no hostname") }
|
|
sel, scope, _ = observeLogSelector("sshd")
|
|
if strings.Contains(sel, "node=") {
|
|
t.Fatalf("no hostname must mean no node matcher: %q", sel)
|
|
}
|
|
if scope != observeScopeCluster {
|
|
t.Fatalf("an unscoped answer must not claim node scope: %q", scope)
|
|
}
|
|
}
|
|
|
|
// An empty answer is the most misreadable one this op gives. Measured on a
|
|
// real box: the bastion's own service can run for hours writing zero journal
|
|
// lines, so this is the normal case, not an edge.
|
|
func TestObserveLogsEmptyResultSaysSo(t *testing.T) {
|
|
fakeMonitoring(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`{"status":"success","data":{"resultType":"streams","result":[]}}`))
|
|
})
|
|
for _, name := range logNames() {
|
|
out, errOut, code := driveObserve(t, "observe:logs", []string{name, "30"})
|
|
if code != 0 {
|
|
t.Fatalf("%s: exit %d %s", name, code, errOut)
|
|
}
|
|
var rep logsReport
|
|
if err := json.Unmarshal([]byte(out), &rep); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rep.Lines != 0 || rep.Log != "" {
|
|
t.Fatalf("%s: %+v", name, rep)
|
|
}
|
|
if rep.Note == "" {
|
|
t.Fatalf("%s: an empty body reads as 'nothing happened'; it must say "+
|
|
"which selector came back with nothing", name)
|
|
}
|
|
if !strings.Contains(rep.Note, rep.Selector) || !strings.Contains(rep.Note, "30 minutes") {
|
|
t.Fatalf("%s: the note must name the selector and the window: %q", name, rep.Note)
|
|
}
|
|
if rep.Scope == "" {
|
|
t.Fatalf("%s: every answer states its scope", name)
|
|
}
|
|
}
|
|
// A non-empty answer carries no note — the note is the absence marker, not
|
|
// a decoration on every reply.
|
|
fakeMonitoring(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(`{"status":"success","data":{"resultType":"streams","result":[
|
|
{"stream":{},"values":[["100","a line"]]}]}}`))
|
|
})
|
|
out, _, _ := driveObserve(t, "observe:logs", []string{"sshd"})
|
|
var rep logsReport
|
|
if err := json.Unmarshal([]byte(out), &rep); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rep.Note != "" {
|
|
t.Fatalf("a non-empty answer must carry no note: %q", rep.Note)
|
|
}
|
|
}
|
|
|
|
// ── the counts must be TRUE even when the rows are capped ────────────────────
|
|
|
|
// `firing` used to saturate at the row cap, because the cap was applied to the
|
|
// raw list before anything looked at it. An estate with 400 alerts firing then
|
|
// produced the same beat as one with 200 — the number a human reads as severity,
|
|
// stuck at a constant.
|
|
func TestAlertCountsAreNotCappedWithTheRows(t *testing.T) {
|
|
const n = 250
|
|
var rows []string
|
|
for i := 0; i < n; i++ {
|
|
sev := "warning"
|
|
if i%5 == 0 {
|
|
sev = "critical"
|
|
}
|
|
rows = append(rows, fmt.Sprintf(
|
|
`{"fingerprint":"fp%03d","labels":{"alertname":"A%03d","severity":%q},`+
|
|
`"startsAt":"2026-09-15T01:00:00Z","status":{"state":"active"}}`, i, i, sev))
|
|
}
|
|
rep, err := parseAlerts([]byte("[" + strings.Join(rows, ",") + "]"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rep.ActiveTotal != n {
|
|
t.Fatalf("active_total must be the real number: %d", rep.ActiveTotal)
|
|
}
|
|
if rep.Count != observeAlertCap || len(rep.Alerts) != observeAlertCap {
|
|
t.Fatalf("the ROWS are capped at %d: count=%d rows=%d", observeAlertCap, rep.Count, len(rep.Alerts))
|
|
}
|
|
if !rep.Truncated {
|
|
t.Fatal("a row cut must be reported")
|
|
}
|
|
|
|
s := summarizeAlerts(rep, time.Now())
|
|
if s.Firing != n {
|
|
t.Fatalf("firing must count every active alert, not the capped rows: %d (want %d)", s.Firing, n)
|
|
}
|
|
if s.Critical != 50 || s.Warning != 200 {
|
|
t.Fatalf("the severity counts come from the full set too: %+v", s)
|
|
}
|
|
if len(s.Fingerprints) != alertsFingerprintCap {
|
|
t.Fatalf("only the fingerprint LIST is capped: %d", len(s.Fingerprints))
|
|
}
|
|
// The contract stays exactly six keys — the counts got truer, not wider.
|
|
b, _ := json.Marshal(s)
|
|
var m map[string]any
|
|
if err := json.Unmarshal(b, &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(m) != 6 {
|
|
t.Fatalf("the six-key contract must not change: %v", m)
|
|
}
|
|
}
|
|
|
|
// The state filter runs BEFORE the cap, so 200 silenced alerts cannot crowd the
|
|
// active ones out of the rows.
|
|
func TestTheStateFilterRunsBeforeTheRowCap(t *testing.T) {
|
|
var rows []string
|
|
for i := 0; i < observeAlertCap; i++ {
|
|
rows = append(rows, fmt.Sprintf(
|
|
`{"fingerprint":"sil%03d","labels":{"alertname":"S","severity":"critical"},`+
|
|
`"startsAt":"2026-09-10T01:00:00Z","status":{"state":"suppressed"}}`, i))
|
|
}
|
|
rows = append(rows, `{"fingerprint":"real","labels":{"alertname":"Real","severity":"critical"},`+
|
|
`"startsAt":"2026-09-15T01:00:00Z","status":{"state":"active"}}`)
|
|
rep, err := parseAlerts([]byte("[" + strings.Join(rows, ",") + "]"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rep.ActiveTotal != 1 || rep.Count != 1 {
|
|
t.Fatalf("200 silences must not push the one real alert out of the answer: %+v", rep.Count)
|
|
}
|
|
if rep.Alerts[0].Fingerprint != "real" {
|
|
t.Fatalf("%+v", rep.Alerts[0])
|
|
}
|
|
if s := summarizeAlerts(rep, time.Now()); s.Firing != 1 || s.Critical != 1 {
|
|
t.Fatalf("%+v", s)
|
|
}
|
|
}
|
|
|
|
// ── removing the alertmanager key stops the beat claiming to watch ───────────
|
|
|
|
// A transient failure keeps the last good value; UNCONFIGURED clears it. Getting
|
|
// that backwards froze a summary onto every beat until the process restarted.
|
|
func TestUnconfiguredClearsTheCachedSummaryButAFailureDoesNot(t *testing.T) {
|
|
alertsMu.Lock()
|
|
alertsLast = nil
|
|
alertsMu.Unlock()
|
|
defer func() {
|
|
alertsMu.Lock()
|
|
alertsLast = nil
|
|
alertsMu.Unlock()
|
|
}()
|
|
|
|
restore := func() func() {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = w.Write([]byte(fxAlerts))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return withEndpointsFile(t, fmt.Sprintf(`{"alertmanager":%q}`, srv.URL))
|
|
}()
|
|
refreshAlerts(context.Background())
|
|
if alertsPayload() == nil {
|
|
t.Fatal("configured and reachable: there must be a summary")
|
|
}
|
|
|
|
// A transient failure — the Alertmanager is named but unreachable. The
|
|
// alerts are still firing; this box just cannot see them this minute.
|
|
origGet := observeHTTPGet
|
|
observeHTTPGet = func(context.Context, string) ([]byte, int, error) {
|
|
return nil, 0, errors.New("connection refused")
|
|
}
|
|
refreshAlerts(context.Background())
|
|
observeHTTPGet = origGet
|
|
if alertsPayload() == nil {
|
|
t.Fatal("a transient failure must KEEP the last good summary")
|
|
}
|
|
|
|
// The admin removes the key. This box is no longer watching anything and
|
|
// must stop saying otherwise.
|
|
restore()
|
|
defer withEndpointsFile(t, `{"loki":"http://127.0.0.1:3100"}`)()
|
|
refreshAlerts(context.Background())
|
|
if alertsPayload() != nil {
|
|
t.Fatal("with the alertmanager key removed the summary must be CLEARED, " +
|
|
"not frozen onto every beat until restart")
|
|
}
|
|
raw := buildHeartbeatBody("", "box", "linux", "test", "pub", "fp", "", "", false, nil, nil, nil)
|
|
if strings.Contains(raw, `"alerts"`) {
|
|
t.Fatalf("and the key must be absent from the beat again: %s", raw)
|
|
}
|
|
|
|
// The file going away entirely is the same story.
|
|
refreshAlerts(context.Background())
|
|
defer withNoEndpointsFile(t)()
|
|
refreshAlerts(context.Background())
|
|
if alertsPayload() != nil {
|
|
t.Fatal("no endpoints file at all must clear it too")
|
|
}
|
|
}
|