217 lines
6.0 KiB
Go
217 lines
6.0 KiB
Go
package cluster
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
|
|
"scm.bstein.dev/bstein/ananke/internal/config"
|
|
"scm.bstein.dev/bstein/ananke/internal/state"
|
|
)
|
|
|
|
func TestParseVaultSealed(t *testing.T) {
|
|
sealed, err := parseVaultSealed(`{"initialized":true,"sealed":true}`)
|
|
if err != nil {
|
|
t.Fatalf("parse sealed=true: %v", err)
|
|
}
|
|
if !sealed {
|
|
t.Fatalf("expected sealed=true")
|
|
}
|
|
|
|
sealed, err = parseVaultSealed(`{"initialized":true,"sealed":false}`)
|
|
if err != nil {
|
|
t.Fatalf("parse sealed=false: %v", err)
|
|
}
|
|
if sealed {
|
|
t.Fatalf("expected sealed=false")
|
|
}
|
|
}
|
|
|
|
func TestParseVaultSealedRejectsEmpty(t *testing.T) {
|
|
if _, err := parseVaultSealed(" "); err == nil {
|
|
t.Fatalf("expected parse error for empty status payload")
|
|
}
|
|
}
|
|
|
|
func TestParseVaultSealedWithKubectlPreamble(t *testing.T) {
|
|
raw := "Defaulted container \"vault\" out of: vault, setup-config (init)\n{\"sealed\":true,\"initialized\":true}\n"
|
|
sealed, err := parseVaultSealed(raw)
|
|
if err != nil {
|
|
t.Fatalf("parse with preamble: %v", err)
|
|
}
|
|
if !sealed {
|
|
t.Fatalf("expected sealed=true from payload with preamble")
|
|
}
|
|
}
|
|
|
|
func TestFallbackWorkersFromInventoryUsesManagedNodes(t *testing.T) {
|
|
orch := &Orchestrator{
|
|
cfg: config.Config{
|
|
ControlPlanes: []string{"titan-0a", "titan-0b", "titan-0c"},
|
|
SSHManagedNodes: []string{
|
|
"titan-db",
|
|
"titan-0a",
|
|
"titan-15",
|
|
"titan-17",
|
|
},
|
|
},
|
|
log: log.New(os.Stdout, "", 0),
|
|
}
|
|
got := orch.fallbackWorkersFromInventory()
|
|
want := []string{"titan-15", "titan-17", "titan-db"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("fallback workers mismatch: got=%v want=%v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestFallbackWorkersFromInventoryFallsBackToHosts(t *testing.T) {
|
|
orch := &Orchestrator{
|
|
cfg: config.Config{
|
|
ControlPlanes: []string{"titan-0a", "titan-0b", "titan-0c"},
|
|
SSHNodeHosts: map[string]string{
|
|
"titan-0a": "192.168.22.11",
|
|
"titan-22": "192.168.22.22",
|
|
"titan-24": "192.168.22.26",
|
|
},
|
|
},
|
|
log: log.New(os.Stdout, "", 0),
|
|
}
|
|
got := orch.fallbackWorkersFromInventory()
|
|
want := []string{"titan-22", "titan-24"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("fallback workers mismatch: got=%v want=%v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestIntentFreshTreatsZeroTimestampAsFresh(t *testing.T) {
|
|
if !intentFresh(state.Intent{}, 30*time.Second) {
|
|
t.Fatalf("zero updated_at intent should be treated as fresh")
|
|
}
|
|
}
|
|
|
|
func TestIntentFreshRespectsAge(t *testing.T) {
|
|
stale := state.Intent{UpdatedAt: time.Now().Add(-2 * time.Minute)}
|
|
fresh := state.Intent{UpdatedAt: time.Now().Add(-20 * time.Second)}
|
|
if intentFresh(stale, 30*time.Second) {
|
|
t.Fatalf("expected stale intent to be considered not fresh")
|
|
}
|
|
if !intentFresh(fresh, 30*time.Second) {
|
|
t.Fatalf("expected recent intent to be considered fresh")
|
|
}
|
|
}
|
|
|
|
func TestCoordinationPeersDedupesAndIncludesForwardHost(t *testing.T) {
|
|
orch := &Orchestrator{
|
|
cfg: config.Config{
|
|
Coordination: config.Coordination{
|
|
PeerHosts: []string{"titan-24", "titan-db", "titan-24", " "},
|
|
ForwardShutdownHost: "titan-db",
|
|
},
|
|
},
|
|
}
|
|
got := orch.coordinationPeers()
|
|
want := []string{"titan-24", "titan-db"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("coordination peers mismatch: got=%v want=%v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestWorkloadTargetsIgnoredNodesByNodeSelector(t *testing.T) {
|
|
spec := podSpec{
|
|
NodeSelector: map[string]string{
|
|
"kubernetes.io/hostname": "titan-22",
|
|
},
|
|
}
|
|
ignored := map[string]struct{}{"titan-22": {}}
|
|
if !workloadTargetsIgnoredNodes(spec, ignored) {
|
|
t.Fatalf("expected workload to target ignored node via nodeSelector")
|
|
}
|
|
}
|
|
|
|
func TestParseWorkloadIgnoreRules(t *testing.T) {
|
|
rules := parseWorkloadIgnoreRules([]string{
|
|
"maintenance/metis",
|
|
"crypto/statefulset/monerod",
|
|
})
|
|
if len(rules) != 2 {
|
|
t.Fatalf("expected 2 ignore rules, got %d", len(rules))
|
|
}
|
|
if !workloadIgnored(rules, "maintenance", "deployment", "metis") {
|
|
t.Fatalf("expected namespace/name rule to match")
|
|
}
|
|
if !workloadIgnored(rules, "crypto", "statefulset", "monerod") {
|
|
t.Fatalf("expected namespace/kind/name rule to match")
|
|
}
|
|
if workloadIgnored(rules, "crypto", "deployment", "monerod") {
|
|
t.Fatalf("did not expect mismatched kind to match")
|
|
}
|
|
}
|
|
|
|
func TestNamespaceCandidatesFromIgnoreKustomizations(t *testing.T) {
|
|
got := namespaceCandidatesFromIgnoreKustomizations([]string{
|
|
"flux-system/jellyfin",
|
|
"flux-system/outline",
|
|
})
|
|
if _, ok := got["jellyfin"]; !ok {
|
|
t.Fatalf("expected jellyfin namespace candidate")
|
|
}
|
|
if _, ok := got["outline"]; !ok {
|
|
t.Fatalf("expected outline namespace candidate")
|
|
}
|
|
}
|
|
|
|
func TestProbeStatusAcceptedRejects404(t *testing.T) {
|
|
if probeStatusAccepted("https://metrics.bstein.dev/login", 404) {
|
|
t.Fatalf("expected 404 probe status to be rejected")
|
|
}
|
|
}
|
|
|
|
func TestServiceCheckReadyRequiresBodyContains(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"database":"ok"}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
orch := &Orchestrator{
|
|
log: log.New(os.Stdout, "", 0),
|
|
}
|
|
ok, detail := orch.serviceCheckReady(context.Background(), config.ServiceChecklistCheck{
|
|
Name: "grafana-api",
|
|
URL: srv.URL,
|
|
AcceptedStatuses: []int{200},
|
|
BodyContains: `"database":"ok"`,
|
|
TimeoutSeconds: 5,
|
|
})
|
|
if !ok {
|
|
t.Fatalf("expected service check to pass, detail=%s", detail)
|
|
}
|
|
}
|
|
|
|
func TestServiceCheckReadyBodyContainsIgnoresWhitespace(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("{\n \"database\": \"ok\"\n}\n"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
orch := &Orchestrator{
|
|
log: log.New(os.Stdout, "", 0),
|
|
}
|
|
ok, detail := orch.serviceCheckReady(context.Background(), config.ServiceChecklistCheck{
|
|
Name: "grafana-api",
|
|
URL: srv.URL,
|
|
AcceptedStatuses: []int{200},
|
|
BodyContains: `"database":"ok"`,
|
|
TimeoutSeconds: 5,
|
|
})
|
|
if !ok {
|
|
t.Fatalf("expected whitespace-tolerant service check to pass, detail=%s", detail)
|
|
}
|
|
}
|