test(metis): cover service helper branches

This commit is contained in:
codex 2026-04-21 05:45:08 -03:00
parent 59e5c15c38
commit 3566e28936
3 changed files with 316 additions and 0 deletions

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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")