Files
Beach/internal/beach/collectors_test.go
2026-10-27 16:57:53 -06:00

254 lines
9.3 KiB
Go

package beach
import (
"context"
"encoding/binary"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestParseContainers(t *testing.T) {
body := `[
{"Id":"b","Names":["/zeta"],"Image":"nginx","State":"exited","Status":"Exited (0) 2 days ago","Created":1},
{"Id":"a","Names":["/alpha"],"Image":"redis","State":"running","Status":"Up 3 hours (healthy)","Created":2,
"Ports":[{"PrivatePort":6379,"PublicPort":6380,"Type":"tcp"},{"PrivatePort":6379,"PublicPort":6380,"Type":"tcp"},{"PrivatePort":9,"Type":"tcp"}]}]`
cs, err := parseContainers([]byte(body))
if err != nil {
t.Fatal(err)
}
if len(cs) != 2 || cs[0].Name != "alpha" {
t.Fatalf("running containers should sort first: %+v", cs)
}
if len(cs[0].Ports) != 1 || cs[0].Ports[0] != "6380:6379/tcp" {
t.Fatalf("ports not deduplicated or unpublished port kept: %v", cs[0].Ports)
}
if cs[1].Ports == nil {
t.Fatal("ports must be an empty array, not null")
}
}
func frame(stream byte, s string) []byte {
h := make([]byte, 8)
h[0] = stream
binary.BigEndian.PutUint32(h[4:], uint32(len(s)))
return append(h, s...)
}
func TestDemux(t *testing.T) {
in := append(frame(1, "hello\n"), frame(2, "oops\n")...)
if got := demux(in); got != "hello\noops\n" {
t.Fatalf("got %q", got)
}
if got := demux([]byte("2026-01-01T00:00:00Z plain tty line\n")); !strings.Contains(got, "plain tty line") {
t.Fatalf("unframed text lost: %q", got)
}
}
func TestDockerAPI(t *testing.T) {
var calls []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.Method+" "+r.URL.Path)
switch {
case r.URL.Path == "/containers/json":
w.Write([]byte(`[{"Id":"abc","Names":["/web"],"State":"running","Status":"Up"}]`))
case strings.HasSuffix(r.URL.Path, "/json"):
w.Write([]byte(`{"Config":{"Tty":false}}`))
case strings.HasSuffix(r.URL.Path, "/logs"):
w.Write(frame(1, "line one\n"))
case strings.HasSuffix(r.URL.Path, "/stop"):
w.WriteHeader(304)
case strings.HasSuffix(r.URL.Path, "/start"):
w.WriteHeader(404)
w.Write([]byte(`{"message":"No such container: abc"}`))
}
}))
defer srv.Close()
d := NewDocker("tcp://" + strings.TrimPrefix(srv.URL, "http://"))
ctx := context.Background()
cs, err := d.List(ctx)
if err != nil || len(cs) != 1 || cs[0].Name != "web" {
t.Fatalf("list: %v %+v", err, cs)
}
if out, err := d.Logs(ctx, "abc", 50); err != nil || out != "line one\n" {
t.Fatalf("logs: %q %v", out, err)
}
if err := d.Action(ctx, "abc", "stop"); err != nil {
t.Fatalf("304 means already stopped, not an error: %v", err)
}
if err := d.Action(ctx, "abc", "start"); err == nil || !strings.Contains(err.Error(), "No such container") {
t.Fatalf("engine message should surface: %v", err)
}
if err := d.Action(ctx, "abc", "rm"); err == nil {
t.Fatal("unknown action must be refused")
}
}
func TestTrueNAS(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer secret" {
w.WriteHeader(401)
return
}
switch r.URL.Path {
case "/api/v2.0/pool":
w.Write([]byte(`[{"name":"tank","status":"ONLINE","healthy":true,"size":1000,"allocated":250,
"scan":{"function":"SCRUB","state":"FINISHED","errors":0}},
{"name":"fast","status":"DEGRADED","healthy":false,"size":0,"allocated":0,"scan":{"function":"SCRUB","state":"SCANNING","percentage":41.2}}]`))
case "/api/v2.0/disk":
w.Write([]byte(`[{"name":"sdb","model":"WD","serial":"S2","size":4000,"pool":"tank"},{"name":"sda","model":"WD","serial":"S1","size":4000,"pool":null}]`))
case "/api/v2.0/disk/temperatures":
w.Write([]byte(`{"sda":34,"sdb":null}`))
case "/api/v2.0/alert/list":
w.Write([]byte(`[{"level":"WARNING","formatted":"Pool degraded","dismissed":false},{"level":"INFO","formatted":"old","dismissed":true}]`))
default:
w.WriteHeader(404)
}
}))
defer srv.Close()
st := NewTrueNAS(srv.URL, "secret", false).Collect(context.Background())
if st.Error != "" {
t.Fatal(st.Error)
}
if len(st.Pools) != 2 || st.Pools[0].Name != "fast" {
t.Fatalf("pools: %+v", st.Pools)
}
tank := st.Pools[1]
if tank.UsedPct != 25 || tank.Scan != "scrub finished, 0 errors" || !tank.Healthy {
t.Fatalf("tank: %+v", tank)
}
if st.Pools[0].Scan != "scrub in progress 41%" {
t.Fatalf("scan progress: %q", st.Pools[0].Scan)
}
if len(st.Disks) != 2 || st.Disks[0].Name != "sda" || st.Disks[0].TempC != 34 || st.Disks[1].TempC != 0 {
t.Fatalf("disks: %+v", st.Disks)
}
if len(st.Alerts) != 1 || st.Alerts[0].Message != "Pool degraded" {
t.Fatalf("dismissed alerts must be hidden: %+v", st.Alerts)
}
bad := NewTrueNAS(srv.URL, "wrong", false).Collect(context.Background())
if !strings.Contains(bad.Error, "API key rejected") {
t.Fatalf("bad key error: %q", bad.Error)
}
}
const smartJSON = `{"device":{"name":"/dev/sda"},"model_name":"ST4000","serial_number":"Z1","user_capacity":{"bytes":4000787030016},
"smart_status":{"passed":%s},"temperature":{"current":38}}`
func TestParseSmartctl(t *testing.T) {
d, err := parseSmartctl([]byte(strings.Replace(smartJSON, "%s", "true", 1)))
if err != nil || d.Health != "PASSED" || d.TempC != 38 || d.Model != "ST4000" {
t.Fatalf("%+v %v", d, err)
}
d, _ = parseSmartctl([]byte(strings.Replace(smartJSON, "%s", "false", 1)))
if d.Health != "FAILED" {
t.Fatalf("failed disk reported as %q", d.Health)
}
d, _ = parseSmartctl([]byte(`{"device":{"name":"/dev/sdc"}}`))
if d.Health != "" {
t.Fatalf("no verdict must stay unknown, got %q", d.Health)
}
}
func TestLocalSmartAndMerge(t *testing.T) {
fake := func(ctx context.Context, args ...string) ([]byte, error) {
if args[0] == "--scan" {
return []byte(`{"devices":[{"name":"/dev/sda"},{"name":"/dev/sdz"}]}`), nil
}
if args[len(args)-1] == "/dev/sda" {
return []byte(strings.Replace(smartJSON, "%s", "true", 1)), nil
}
return []byte(`{"device":{"name":"/dev/sdz"},"serial_number":"ONLY","smart_status":{"passed":false}}`), nil
}
local, err := LocalSmart(context.Background(), fake)
if err != nil || len(local) != 2 {
t.Fatalf("%v %+v", err, local)
}
tn := []Disk{{Name: "sda", Serial: "Z1", Source: "truenas"}}
merged := mergeDisks(tn, local)
if len(merged) != 2 || merged[0].Health != "PASSED" || merged[0].TempC != 38 || merged[0].Source != "truenas" {
t.Fatalf("same disk should be merged, not duplicated: %+v", merged)
}
if merged[1].Serial != "ONLY" || merged[1].Health != "FAILED" {
t.Fatalf("smartctl-only disk missing: %+v", merged)
}
}
func TestHostParsers(t *testing.T) {
a, err := parseCPU("cpu 100 0 100 700 100 0 0 0 0 0\ncpu0 1 1 1 1 1 1 1 1 1 1\n")
if err != nil {
t.Fatal(err)
}
b, _ := parseCPU("cpu 200 0 200 750 150 0 0 0 0 0\n")
// between samples: busy +200, idle(+iowait) +100
if got := cpuPct(a, b); got < 66 || got > 67 {
t.Fatalf("cpu %.1f", got)
}
if cpuPct(b, a) != 0 {
t.Fatal("a counter reset must not give a negative or huge value")
}
total, used, st, su := parseMeminfo("MemTotal: 1000 kB\nMemAvailable: 400 kB\nSwapTotal: 200 kB\nSwapFree: 150 kB\n")
if total != 1000*1024 || used != 600*1024 || st != 200*1024 || su != 50*1024 {
t.Fatalf("%d %d %d %d", total, used, st, su)
}
if l := parseLoad("0.50 1.25 2.00 1/200 999"); l != [3]float64{0.5, 1.25, 2} {
t.Fatalf("%v", l)
}
if parseUptime("3600.55 7000.1") != 3600.55 {
t.Fatal("uptime")
}
mounts := "/dev/sda1 / ext4 rw 0 0\ntmpfs /run tmpfs rw 0 0\ntank/data /mnt/tank zfs rw 0 0\n/dev/sda1 /var/lib/docker/x ext4 rw 0 0\n/dev/sdb1 /var/lib/docker/y ext4 rw 0 0\n"
if got := realMounts(mounts); len(got) != 2 || got[0] != "/" || got[1] != "/mnt/tank" {
t.Fatalf("%v", got)
}
if h := CollectHost(); h.CPUs < 1 {
t.Fatal("CollectHost should report CPU count")
}
}
func TestParseTunnels(t *testing.T) {
body := `{"sites":[{"id":"s1","name":"homelab","version":"v0.1.8"},{"id":"s2","name":"vps","local":true}],
"online":{"s1":true,"s2":false},
"mappings":[{"name":"wiki","proto":"web","hostname":"wiki.example.com","port":443,"enabled":true,"statusKnown":true,
"targets":[{},{}],"status":{"targets":[{"healthy":true},{"healthy":false}]}},
{"name":"ssh","proto":"tcp","port":22,"enabled":true,"statusKnown":true,"status":{"error":"bind: in use"}},
{"name":"off","proto":"tcp","port":1,"enabled":false}]}`
tn, err := parseTunnels([]byte(body))
if err != nil {
t.Fatal(err)
}
if len(tn.Sites) != 2 || tn.Sites[0].Name != "homelab" || !tn.Sites[0].Online || tn.Sites[1].Online {
t.Fatalf("sites: %+v", tn.Sites)
}
byName := map[string]TunnelPort{}
for _, p := range tn.Ports {
byName[p.Name] = p
}
if w := byName["wiki"]; !w.Up || w.Healthy != 1 || w.Targets != 2 {
t.Fatalf("wiki: %+v", w)
}
if byName["ssh"].Up || byName["off"].Up {
t.Fatal("errored or disabled ports must not be up")
}
}
func TestFetchTunnels(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/orgs/o1/state" || r.Header.Get("Authorization") != "Bearer k" {
w.WriteHeader(401)
return
}
w.Write([]byte(`{"sites":[],"mappings":[],"online":{}}`))
}))
defer srv.Close()
if tn := FetchTunnels(context.Background(), srv.URL+"/", "k", "o1"); tn.Error != "" {
t.Fatal(tn.Error)
}
if tn := FetchTunnels(context.Background(), srv.URL, "nope", "o1"); !strings.Contains(tn.Error, "401") {
t.Fatalf("expected 401 error, got %q", tn.Error)
}
}