diff --git a/pkg/service/node_annotations_test.go b/pkg/service/node_annotations_test.go index 74e7370..5047a7b 100644 --- a/pkg/service/node_annotations_test.go +++ b/pkg/service/node_annotations_test.go @@ -2,6 +2,7 @@ package service import ( "encoding/json" + "errors" "net/http" "net/http/httptest" "strings" @@ -13,6 +14,13 @@ import ( ) func TestScratchHealthAnnotations(t *testing.T) { + if err := newTestApp(t).syncScratchAnnotations(SnapshotRecord{}); err != nil { + t.Fatalf("nil scratch sync should be a noop: %v", err) + } + if status, detail := scratchStatusDetail(nil); status != "missing" || detail != "no-scratch-snapshot" { + t.Fatalf("nil scratch detail mismatch: %s %s", status, detail) + } + observed := time.Date(2026, 4, 22, 6, 45, 0, 0, time.UTC) annotations := scratchHealthAnnotations(&facts.USBScratch{ Mountpoint: "/mnt/astraios", @@ -33,8 +41,19 @@ func TestScratchHealthAnnotations(t *testing.T) { t.Fatalf("managed paths annotation mismatch: %#v", annotations) } - status, detail := scratchStatusDetail(&facts.USBScratch{MountHealthy: false, BindHealthy: false}) - if status != "error" || !strings.Contains(detail, "mount-unhealthy") || !strings.Contains(detail, "bind-mount-incomplete") { + labelAnnotations := scratchHealthAnnotations(&facts.USBScratch{ + Mountpoint: "/mnt/scratch", + Label: "scratch-a", + MountHealthy: true, + LabelHealthy: true, + BindHealthy: true, + }, observed) + if labelAnnotations["maintenance.bstein.dev/usb-scratch-selector"] != "LABEL=scratch-a" { + t.Fatalf("label selector annotation mismatch: %#v", labelAnnotations) + } + + status, detail := scratchStatusDetail(&facts.USBScratch{Label: "scratch-a", MountHealthy: false, LabelHealthy: false, BindHealthy: false}) + if status != "error" || !strings.Contains(detail, "mount-unhealthy") || !strings.Contains(detail, "label-mismatch") || !strings.Contains(detail, "bind-mount-incomplete") { t.Fatalf("unexpected unhealthy detail: %s %s", status, detail) } } @@ -90,3 +109,77 @@ func TestStoreSnapshotPatchesNodeAnnotations(t *testing.T) { t.Fatalf("annotation patch mismatch: %#v", annotations) } } + +func TestStoreSnapshotRecordsAnnotationPatchFailure(t *testing.T) { + kube := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "denied", http.StatusForbidden) + })) + defer kube.Close() + + origFactory := kubeClientFactory + kubeClientFactory = func() (*kubeClient, error) { + return kubeClientFactoryForURL(kube.URL, kube.Client()), nil + } + t.Cleanup(func() { kubeClientFactory = origFactory }) + + app := newTestApp(t) + if err := app.StoreSnapshot(SnapshotRecord{ + Node: "titan-04", + CollectedAt: time.Date(2026, 4, 22, 6, 55, 0, 0, time.UTC), + Snapshot: sentinel.Snapshot{ + Hostname: "titan-04", + USBScratch: &facts.USBScratch{ + Mountpoint: "/mnt/astraios", + MountHealthy: true, + BindHealthy: true, + }, + }, + }); err != nil { + t.Fatalf("StoreSnapshot should keep the snapshot even if annotation sync fails: %v", err) + } + state := app.State("") + found := false + for _, event := range state.Events { + if event.Kind == "sentinel.annotation" { + found = true + break + } + } + if !found { + t.Fatalf("expected annotation warning event, got %#v", state.Events) + } +} + +func TestPatchNodeAnnotationsNoopsAndClientErrors(t *testing.T) { + if err := patchNodeAnnotations("", map[string]string{"a": "b"}); err != nil { + t.Fatalf("empty node should be a noop: %v", err) + } + if err := patchNodeAnnotations("node", nil); err != nil { + t.Fatalf("empty annotations should be a noop: %v", err) + } + + origFactory := kubeClientFactory + kubeClientFactory = func() (*kubeClient, error) { return nil, errors.New("offline") } + if err := patchNodeAnnotations("node", map[string]string{"a": "b"}); err == nil || !strings.Contains(err.Error(), "offline") { + t.Fatalf("expected factory error, got %v", err) + } + t.Cleanup(func() { kubeClientFactory = origFactory }) + + client := kubeClientFactoryForURL("http://127.0.0.1", &http.Client{Transport: failingRoundTripper{}}) + if err := client.mergePatch("/api/v1/nodes/node", map[string]string{"a": "b"}); err == nil || !strings.Contains(err.Error(), "transport down") { + t.Fatalf("expected transport error, got %v", err) + } + if err := client.mergePatch("/api/v1/nodes/node", map[string]any{"bad": func() {}}); err == nil { + t.Fatal("expected marshal error") + } + badURLClient := kubeClientFactoryForURL("http://[::1", http.DefaultClient) + if err := badURLClient.mergePatch("/api/v1/nodes/node", map[string]string{"a": "b"}); err == nil { + t.Fatal("expected request construction error") + } +} + +type failingRoundTripper struct{} + +func (failingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + return nil, errors.New("transport down") +}