254 lines
9.3 KiB
Go
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)
|
|
}
|
|
}
|