From 3566e289369c7eedcfe946593475d487c5c10d17 Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 21 Apr 2026 05:45:08 -0300 Subject: [PATCH] test(metis): cover service helper branches --- pkg/service/helpers_test.go | 109 ++++++++++++++++++ pkg/service/remote_helpers_test.go | 171 +++++++++++++++++++++++++++++ pkg/service/server_test.go | 36 ++++++ 3 files changed, 316 insertions(+) diff --git a/pkg/service/helpers_test.go b/pkg/service/helpers_test.go index 9729c3b..9714e8d 100644 --- a/pkg/service/helpers_test.go +++ b/pkg/service/helpers_test.go @@ -291,6 +291,14 @@ func TestHelperBranchesAndPersistenceFailures(t *testing.T) { if err := app.StoreSnapshot(SnapshotRecord{}); err == nil { t.Fatal("expected snapshot validation error") } + blockedSnapshotParent := filepath.Join(t.TempDir(), "blocked-snapshot-parent") + if err := os.WriteFile(blockedSnapshotParent, []byte("block"), 0o644); err != nil { + t.Fatal(err) + } + app.settings.SnapshotsPath = filepath.Join(blockedSnapshotParent, "valid-snapshot.json") + if err := app.StoreSnapshot(SnapshotRecord{Node: "titan-15"}); err == nil { + t.Fatal("expected snapshot persistence error") + } if _, err := app.Build("missing"); err == nil { t.Fatal("expected Build to reject unknown node") } @@ -314,11 +322,66 @@ func TestNewAppReportsInventoryErrors(t *testing.T) { } } +func TestNewAppReportsDirectoryCreationErrors(t *testing.T) { + dir := t.TempDir() + inventoryPath := filepath.Join(dir, "inventory.yaml") + if err := os.WriteFile(inventoryPath, []byte("classes: []\nnodes: []\n"), 0o644); err != nil { + t.Fatal(err) + } + blocked := filepath.Join(dir, "blocked") + if err := os.WriteFile(blocked, []byte("not-a-dir"), 0o644); err != nil { + t.Fatal(err) + } + base := Settings{ + InventoryPath: inventoryPath, + CacheDir: filepath.Join(dir, "cache"), + ArtifactDir: filepath.Join(dir, "artifacts"), + ArtifactStatePath: filepath.Join(dir, "artifacts.json"), + HistoryPath: filepath.Join(dir, "history.jsonl"), + SnapshotsPath: filepath.Join(dir, "snapshots.json"), + TargetsPath: filepath.Join(dir, "targets.json"), + } + for name, mutate := range map[string]func(*Settings){ + "cache": func(s *Settings) { s.CacheDir = filepath.Join(blocked, "cache") }, + "artifact": func(s *Settings) { + s.ArtifactDir = filepath.Join(blocked, "artifacts") + }, + "history": func(s *Settings) { + s.HistoryPath = filepath.Join(blocked, "history.jsonl") + }, + } { + settings := base + mutate(&settings) + if _, err := NewApp(settings); err == nil { + t.Fatalf("expected NewApp %s directory error", name) + } + } +} + func TestAppHelperNoopAndInvalidStateBranches(t *testing.T) { app := newTestApp(t) app.setJob("missing", func(*Job) { t.Fatal("setJob should not run for missing job") }) app.completeJob("missing", func(*Job) { t.Fatal("completeJob should not run for missing job") }) app.failJob("missing", os.ErrNotExist) + if got := (*activeNodeJobError)(nil).Error(); !strings.Contains(got, "active metis job") { + t.Fatalf("nil activeNodeJobError = %q", got) + } + if got := app.activeJobForNode(""); got != nil { + t.Fatalf("activeJobForNode empty = %#v", got) + } + now := time.Now() + app.jobs["ignored"] = nil + app.jobs["done"] = &Job{ID: "done", Kind: "build", Node: "titan-15", Status: JobDone, StartedAt: now} + app.jobs["other-kind"] = &Job{ID: "other-kind", Kind: "watch", Node: "titan-15", Status: JobRunning, StartedAt: now} + app.jobs["newer"] = &Job{ID: "newer", Kind: "replace", Node: "titan-15", Status: JobRunning, StartedAt: now} + app.jobs["older"] = &Job{ID: "older", Kind: "build", Node: "titan-15", Status: JobQueued, StartedAt: now.Add(-time.Minute)} + if got := app.activeJobForNode("titan-15"); got == nil || got.ID != "older" { + t.Fatalf("activeJobForNode earliest active = %#v", got) + } + if _, err := app.reserveJob("build", "titan-15", "", ""); err == nil { + t.Fatal("expected reserveJob to reject active node") + } + app.appendEvent(Event{Kind: "bad", Details: map[string]any{"bad": make(chan int)}}) if replacementReady(nil, nil) { t.Fatal("replacementReady nil should be false") @@ -330,6 +393,17 @@ func TestAppHelperNoopAndInvalidStateBranches(t *testing.T) { if got := app.replacementNodes(); len(got) != 0 { t.Fatalf("replacementNodes empty inventory = %#v", got) } + app.inventory = &inventory.Inventory{ + Classes: []inventory.NodeClass{{Name: "ready", Image: "img", Checksum: "sum"}}, + Nodes: []inventory.NodeSpec{ + {Name: "z-node", Class: "ready", Hostname: "z-node", IP: "192.168.22.11", K3sRole: "server", K3sToken: "token", SSHUser: "atlas", SSHAuthorized: []string{"ssh-ed25519 AAA"}}, + {Name: "a-node", Class: "ready", Hostname: "a-node", IP: "192.168.22.10", K3sRole: "server", K3sToken: "token", SSHUser: "atlas", SSHAuthorized: []string{"ssh-ed25519 AAA"}}, + {Name: "missing-class", Class: "missing", Hostname: "missing-class", IP: "192.168.22.12", K3sRole: "server", K3sToken: "token", SSHUser: "atlas", SSHAuthorized: []string{"ssh-ed25519 AAA"}}, + }, + } + if got := app.replacementNodes(); len(got) != 2 || got[0].Name != "a-node" || got[1].Name != "z-node" { + t.Fatalf("replacementNodes sorted/filtered = %#v", got) + } app.settings.FlashHosts = []string{"titan-22"} app.settings.DefaultFlashHost = "" if got := app.flashHosts(); len(got) == 0 { @@ -347,6 +421,16 @@ func TestAppHelperNoopAndInvalidStateBranches(t *testing.T) { }, &inventory.NodeClass{Image: "img", Checksum: "sum"}) { t.Fatal("replacementReady valid node should be true") } + for name, node := range map[string]inventory.NodeSpec{ + "missing-role": {Name: "n", Hostname: "n", IP: "1.1.1.1", K3sToken: "token", SSHUser: "atlas", SSHAuthorized: []string{"ssh"}}, + "missing-url": {Name: "n", Hostname: "n", IP: "1.1.1.1", K3sRole: "agent", K3sToken: "token", SSHUser: "atlas", SSHAuthorized: []string{"ssh"}}, + "missing-token": {Name: "n", Hostname: "n", IP: "1.1.1.1", K3sRole: "server", SSHUser: "atlas", SSHAuthorized: []string{"ssh"}}, + "missing-ssh": {Name: "n", Hostname: "n", IP: "1.1.1.1", K3sRole: "server", K3sToken: "token"}, + } { + if replacementReady(&node, &inventory.NodeClass{Image: "img", Checksum: "sum"}) { + t.Fatalf("replacementReady should reject %s", name) + } + } fileParent := filepath.Join(t.TempDir(), "blocked") if err := os.WriteFile(fileParent, []byte("block"), 0o644); err != nil { @@ -357,6 +441,31 @@ func TestAppHelperNoopAndInvalidStateBranches(t *testing.T) { if got := app.recentEvents(1); got != nil { t.Fatalf("recentEvents missing file = %#v", got) } + historyPath := filepath.Join(t.TempDir(), "history.jsonl") + if err := os.WriteFile(historyPath, []byte("{bad-json}\n{\"kind\":\"ok\"}\n"), 0o644); err != nil { + t.Fatal(err) + } + app.settings.HistoryPath = historyPath + if got := app.recentEvents(10); len(got) != 1 || got[0].Kind != "ok" { + t.Fatalf("recentEvents should skip invalid rows: %#v", got) + } + app.settings.SnapshotsPath = historyPath + if err := app.loadSnapshots(); err == nil { + t.Fatal("expected loadSnapshots invalid JSON error") + } + app.settings.TargetsPath = historyPath + if err := app.loadTargets(); err == nil { + t.Fatal("expected loadTargets invalid JSON error") + } + if targetsEqual(facts.Targets{Packages: map[string]string{"a": "1"}}, facts.Targets{Packages: map[string]string{"a": "1", "b": "2"}}) { + t.Fatal("targetsEqual should reject package length mismatch") + } + if targetsEqual(facts.Targets{Packages: map[string]string{"a": "1"}}, facts.Targets{Packages: map[string]string{"a": "2"}}) { + t.Fatal("targetsEqual should reject package value mismatch") + } + if got := firstLine(" one\ntwo "); got != "one" { + t.Fatalf("firstLine = %q", got) + } kube := fakeKubeServer(t) installKubeFactory(t, kube) diff --git a/pkg/service/remote_helpers_test.go b/pkg/service/remote_helpers_test.go index f7a9557..8aa1d6c 100644 --- a/pkg/service/remote_helpers_test.go +++ b/pkg/service/remote_helpers_test.go @@ -21,24 +21,47 @@ func TestRemoteHelperBranches(t *testing.T) { if got := ramp(0, 10, 20, 1, 2); got != 1 { t.Fatalf("ramp before start = %v", got) } + if got := ramp(0, 1, 1, 2, 3); got != 3 { + t.Fatalf("ramp collapsed range = %v", got) + } + if got := ramp(20, 10, 20, 1, 2); got != 2 { + t.Fatalf("ramp after end = %v", got) + } if got := mountedHostTmpDir("/tmp/metis-flash-test"); got != "/host-tmp/metis-flash-test" { t.Fatalf("mountedHostTmpDir = %q", got) } + if got := mountedHostTmpDir("/var/tmp/metis-flash-test"); got != "/host-tmp/var/tmp/metis-flash-test" { + t.Fatalf("mountedHostTmpDir non-tmp = %q", got) + } + if got := shellQuote(""); got != "''" { + t.Fatalf("shellQuote empty = %q", got) + } if got := shellQuote("a'b"); got != `'a'"'"'b'` { t.Fatalf("shellQuote = %q", got) } if got, msg := buildStageHeartbeat("n1", "b1", 5*time.Second); got < 8 || !strings.Contains(msg, "Scheduling") { t.Fatalf("buildStageHeartbeat early = %v %q", got, msg) } + if got, msg := buildStageHeartbeat("n1", "b1", 400*time.Second); got < 58 || got > 70 || !strings.Contains(msg, "Compressing") { + t.Fatalf("buildStageHeartbeat compress = %v %q", got, msg) + } if got, msg := flashStageHeartbeat("h1", "artifact", 15*time.Second); got < 88 || !strings.Contains(msg, "Writing") { t.Fatalf("flashStageHeartbeat = %v %q", got, msg) } app := newTestApp(t) + app.settings.HarborRegistry = "registry.example" + app.settings.HarborProject = "metis" app.artifactStore["n1"] = ArtifactSummary{Node: "n1", Ref: "registry.example/metis/n1:latest"} if got := app.remoteArtifactNote("n1"); got != "registry.example/metis/n1:latest" { t.Fatalf("remoteArtifactNote = %q", got) } + if got := app.remoteArtifactNote("n2"); got != "registry.example/metis/n2:latest" { + t.Fatalf("remoteArtifactNote fallback = %q", got) + } + if _, err := app.ensureDevice("titan-22", ""); err == nil { + t.Fatal("expected empty device selection to fail") + } if got := inventoryNodeArch(&inventory.NodeSpec{}, &inventory.NodeClass{Arch: "amd64"}); got != "amd64" { t.Fatalf("inventoryNodeArch = %q", got) } @@ -126,3 +149,151 @@ func TestSelectBuilderHostAvoidsBusyBuilderWhenPeersAreFree(t *testing.T) { t.Fatalf("expected titan-05 builder, got %s", node.Name) } } + +func TestEnsureDeviceRejectsUnknownCandidate(t *testing.T) { + kube := fakeKubeServer(t) + installKubeFactory(t, kube) + app := newTestApp(t) + app.settings.Namespace = "maintenance" + app.settings.RunnerImageARM64 = "runner:arm64" + + if _, err := app.ensureDevice("titan-22", "/dev/sda"); err == nil { + t.Fatal("expected stale device selection to fail") + } +} + +func TestSelectBuilderHostScoresStorageAndAMD64(t *testing.T) { + kube := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/nodes": + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []any{ + map[string]any{ + "metadata": map[string]any{ + "name": "storage-arm", + "labels": map[string]string{ + "kubernetes.io/arch": "arm64", + "hardware": "rpi5", + "node-role.kubernetes.io/worker": "true", + }, + }, + "spec": map[string]any{"unschedulable": false}, + }, + map[string]any{ + "metadata": map[string]any{ + "name": "free-arm", + "labels": map[string]string{ + "kubernetes.io/arch": "arm64", + "hardware": "rpi5", + "node-role.kubernetes.io/worker": "true", + }, + }, + "spec": map[string]any{"unschedulable": false}, + }, + map[string]any{ + "metadata": map[string]any{ + "name": "desktop", + "labels": map[string]string{ + "kubernetes.io/arch": "amd64", + "node-role.kubernetes.io/worker": "true", + }, + }, + "spec": map[string]any{"unschedulable": false}, + }, + map[string]any{ + "metadata": map[string]any{ + "name": "titan-24", + "labels": map[string]string{ + "kubernetes.io/arch": "amd64", + "node-role.kubernetes.io/worker": "true", + }, + }, + "spec": map[string]any{"unschedulable": false}, + }, + }, + }) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/namespaces/maintenance/pods": + _ = json.NewEncoder(w).Encode(map[string]any{"items": []any{}}) + default: + http.NotFound(w, r) + } + })) + defer kube.Close() + installKubeFactory(t, kube) + app := newTestApp(t) + app.settings.Namespace = "maintenance" + app.settings.DefaultFlashHost = "desktop" + app.inventory.Nodes = []inventory.NodeSpec{ + { + Name: "storage-arm", + LonghornDisks: []inventory.LonghornDisk{{Mountpoint: "/var/lib/longhorn"}}, + }, + } + + node, err := app.selectBuilderHost("arm64", "") + if err != nil { + t.Fatalf("selectBuilderHost arm64: %v", err) + } + if node.Name != "free-arm" { + t.Fatalf("expected free-arm to beat storage node, got %s", node.Name) + } + + node, err = app.selectBuilderHost("amd64", "") + if err != nil { + t.Fatalf("selectBuilderHost amd64: %v", err) + } + if node.Name != "desktop" { + t.Fatalf("expected desktop to beat titan-24, got %s", node.Name) + } +} + +func TestSelectBuilderHostTieBreaksByName(t *testing.T) { + kube := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/nodes": + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []any{ + map[string]any{ + "metadata": map[string]any{ + "name": "beta", + "labels": map[string]string{ + "kubernetes.io/arch": "arm64", + "hardware": "rpi5", + "node-role.kubernetes.io/worker": "true", + }, + }, + "spec": map[string]any{"unschedulable": false}, + }, + map[string]any{ + "metadata": map[string]any{ + "name": "alpha", + "labels": map[string]string{ + "kubernetes.io/arch": "arm64", + "hardware": "rpi5", + "node-role.kubernetes.io/worker": "true", + }, + }, + "spec": map[string]any{"unschedulable": false}, + }, + }, + }) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/namespaces/maintenance/pods": + _ = json.NewEncoder(w).Encode(map[string]any{"items": []any{}}) + default: + http.NotFound(w, r) + } + })) + defer kube.Close() + installKubeFactory(t, kube) + app := newTestApp(t) + app.settings.Namespace = "maintenance" + app.inventory.Nodes = nil + + node, err := app.selectBuilderHost("arm64", "") + if err != nil { + t.Fatalf("selectBuilderHost: %v", err) + } + if node.Name != "alpha" { + t.Fatalf("expected alphabetical tie-breaker, got %s", node.Name) + } +} diff --git a/pkg/service/server_test.go b/pkg/service/server_test.go index edfbd59..829b433 100644 --- a/pkg/service/server_test.go +++ b/pkg/service/server_test.go @@ -316,6 +316,42 @@ func TestHTTPHandlersExerciseErrorBranches(t *testing.T) { } } +func TestHTTPHandlersAdditionalErrorBranches(t *testing.T) { + app := newTestApp(t) + handler := app.Handler() + + blocked := filepath.Join(t.TempDir(), "blocked") + if err := os.WriteFile(blocked, []byte("block"), 0o644); err != nil { + t.Fatal(err) + } + app.settings.SnapshotsPath = filepath.Join(blocked, "snapshots.json") + payload := `{"node":"titan-15","snapshot":{"hostname":"titan-15"}}` + req := httptest.NewRequest(http.MethodPost, "/internal/sentinel/snapshot", strings.NewReader(payload)) + resp := httptest.NewRecorder() + handler.ServeHTTP(resp, req) + if resp.Code != http.StatusBadRequest { + t.Fatalf("expected snapshot persistence error, got %d", resp.Code) + } + + for _, path := range []string{"/internal/sentinel/watch", "/api/jobs/build", "/api/jobs/replace", "/api/sentinel/watch"} { + req = httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("X-Auth-Request-User", "brad") + req.Header.Set("X-Auth-Request-Groups", "admin") + resp = httptest.NewRecorder() + handler.ServeHTTP(resp, req) + if resp.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected method-not-allowed for %s, got %d", path, resp.Code) + } + } + + req = httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Auth-Request-User", "brad") + req.Header.Set("X-Auth-Request-Groups", "viewers") + if _, ok := app.authorize(req); ok { + t.Fatal("authorize should reject non-allowed groups") + } +} + func TestWatchHandlersReturnErrorsWhenTargetsCannotPersist(t *testing.T) { app := newTestApp(t) blocked := filepath.Join(t.TempDir(), "blocked")