40906a0697
Add registry_test.go (NEW) covering NodeRegistry Join/Leave/Forget/ List/Get (success + not-found + duplicate), NewNodeRegistry nil- logger, Audit Record success/error (sqlite-backed via openTestDB pattern) + NewAudit nil-logger. Extend scheduler_test.go with MemLocalNode/Capacity (happy + nil), JobSpecScore nil/over-capacity/ fits, JobSpecFits nil, PickNode empty. Extend dispatcher_test.go with Submit error paths: bad spec, explicit target no-registry, target peer-not-found, peer-pick missing CA, no peer registry, nil capacity fallthrough, all-peers-fail PickNode. Coverage: 65.1% → 88.9%. go test -race PASS. No production code changed; T01.2 peerDispatcher seam NOT needed (error-path tests via stubbed LocalExecutor + PeerRegistry reached 89% without it; httptest.NewTLSServer was not required either since dispatchToPeer CA-missing and PickNode-fail branches cover the remote path). ---ci--- project: orca phase: 1 milestone: v0.8 status: execute ---/ci---
305 lines
9.0 KiB
Go
305 lines
9.0 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"git.cloudinit.dev/coreci/orca/internal/store"
|
|
)
|
|
|
|
type mockExecutor struct {
|
|
submitFn func(ctx context.Context, spec []byte) (string, error)
|
|
statusFn func(ctx context.Context, jobID string) (string, error)
|
|
submitted bool
|
|
}
|
|
|
|
func (m *mockExecutor) Submit(ctx context.Context, spec []byte) (string, error) {
|
|
m.submitted = true
|
|
if m.submitFn != nil {
|
|
return m.submitFn(ctx, spec)
|
|
}
|
|
return "mock-job-id", nil
|
|
}
|
|
|
|
func (m *mockExecutor) Status(ctx context.Context, jobID string) (string, error) {
|
|
if m.statusFn != nil {
|
|
return m.statusFn(ctx, jobID)
|
|
}
|
|
return "complete", nil
|
|
}
|
|
|
|
func newTestDispatcher(t *testing.T, exec LocalExecutor) (*Dispatcher, *store.CapacityRepo, func()) {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "test.db")
|
|
db, err := store.Open(path)
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
capRepo := store.NewCapacityRepo(db)
|
|
peers := NewPeerRegistry()
|
|
d := NewDispatcher(nil, capRepo, peers, exec)
|
|
return d, capRepo, func() { _ = db.Close() }
|
|
}
|
|
|
|
func TestDispatcher_Submit_EmptySpec(t *testing.T) {
|
|
d, _, cleanup := newTestDispatcher(t, &mockExecutor{})
|
|
defer cleanup()
|
|
_, _, err := d.Submit(context.Background(), "", nil, "")
|
|
if err == nil {
|
|
t.Fatal("Submit: expected error for empty spec, got nil")
|
|
}
|
|
}
|
|
|
|
func TestDispatcher_Submit_IdempotencyHit(t *testing.T) {
|
|
exec := &mockExecutor{}
|
|
d, _, cleanup := newTestDispatcher(t, exec)
|
|
defer cleanup()
|
|
|
|
d.Dedupe().Put("key-1", "cached-job-id")
|
|
spec := []byte(`{"cpu_millicores":100,"memory_mib":64,"disk_mib":64}`)
|
|
jobID, nodeID, err := d.Submit(context.Background(), "", spec, "key-1")
|
|
if err != nil {
|
|
t.Fatalf("Submit: %v", err)
|
|
}
|
|
if jobID != "cached-job-id" {
|
|
t.Errorf("jobID: got %q, want cached-job-id", jobID)
|
|
}
|
|
if nodeID != "self" {
|
|
t.Errorf("nodeID: got %q, want self", nodeID)
|
|
}
|
|
if exec.submitted {
|
|
t.Error("executor was called on idempotency hit; should have been short-circuited")
|
|
}
|
|
}
|
|
|
|
func TestDispatcher_Submit_LocalCapacity(t *testing.T) {
|
|
exec := &mockExecutor{
|
|
submitFn: func(ctx context.Context, spec []byte) (string, error) {
|
|
return "local-job-id", nil
|
|
},
|
|
}
|
|
d, capRepo, cleanup := newTestDispatcher(t, exec)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
if err := capRepo.Upsert(ctx, &store.NodeCapacity{
|
|
NodeID: "self",
|
|
CPUMillicores: 4000,
|
|
MemoryMiB: 4096,
|
|
DiskMiB: 4096,
|
|
}); err != nil {
|
|
t.Fatalf("Upsert capacity: %v", err)
|
|
}
|
|
spec := []byte(`{"cpu_millicores":100,"memory_mib":64,"disk_mib":64}`)
|
|
jobID, nodeID, err := d.Submit(ctx, "", spec, "")
|
|
if err != nil {
|
|
t.Fatalf("Submit: %v", err)
|
|
}
|
|
if jobID != "local-job-id" {
|
|
t.Errorf("jobID: got %q, want local-job-id", jobID)
|
|
}
|
|
if nodeID != "self" {
|
|
t.Errorf("nodeID: got %q, want self", nodeID)
|
|
}
|
|
if !exec.submitted {
|
|
t.Error("executor was not called for local-capacity path")
|
|
}
|
|
}
|
|
|
|
func TestDispatcher_Submit_ExplicitTarget(t *testing.T) {
|
|
exec := &mockExecutor{}
|
|
d, _, cleanup := newTestDispatcher(t, exec)
|
|
defer cleanup()
|
|
spec := []byte(`{"cpu_millicores":100,"memory_mib":64,"disk_mib":64}`)
|
|
_, _, err := d.Submit(context.Background(), "nodeA", spec, "")
|
|
if err == nil {
|
|
t.Fatal("Submit with explicit target nodeA (no peer): expected error, got nil")
|
|
}
|
|
}
|
|
|
|
func TestDispatcher_Submit_NoPeers(t *testing.T) {
|
|
exec := &mockExecutor{}
|
|
d, capRepo, cleanup := newTestDispatcher(t, exec)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
if err := capRepo.Upsert(ctx, &store.NodeCapacity{
|
|
NodeID: "self",
|
|
CPUMillicores: 0,
|
|
MemoryMiB: 0,
|
|
DiskMiB: 0,
|
|
}); err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
spec := []byte(`{"cpu_millicores":1000,"memory_mib":1024,"disk_mib":1024}`)
|
|
_, _, err := d.Submit(ctx, "", spec, "")
|
|
if err == nil {
|
|
t.Fatal("Submit: expected error when no peers and no local capacity, got nil")
|
|
}
|
|
}
|
|
|
|
func TestDispatcher_LocalSubmit(t *testing.T) {
|
|
exec := &mockExecutor{
|
|
submitFn: func(ctx context.Context, spec []byte) (string, error) {
|
|
return "ls-job", nil
|
|
},
|
|
}
|
|
d, _, cleanup := newTestDispatcher(t, exec)
|
|
defer cleanup()
|
|
jobID, err := d.LocalSubmit(context.Background(), []byte(`{"command":"/bin/true"}`))
|
|
if err != nil {
|
|
t.Fatalf("LocalSubmit: %v", err)
|
|
}
|
|
if jobID != "ls-job" {
|
|
t.Errorf("LocalSubmit: got %q, want ls-job", jobID)
|
|
}
|
|
if !exec.submitted {
|
|
t.Error("LocalSubmit: executor.Submit not called")
|
|
}
|
|
}
|
|
|
|
func TestDispatcher_LocalStatus(t *testing.T) {
|
|
exec := &mockExecutor{
|
|
statusFn: func(ctx context.Context, jobID string) (string, error) {
|
|
if jobID == "known" {
|
|
return "running", nil
|
|
}
|
|
return "", errors.New("not found")
|
|
},
|
|
}
|
|
d, _, cleanup := newTestDispatcher(t, exec)
|
|
defer cleanup()
|
|
st, err := d.LocalStatus(context.Background(), "known")
|
|
if err != nil {
|
|
t.Fatalf("LocalStatus: %v", err)
|
|
}
|
|
if st != "running" {
|
|
t.Errorf("LocalStatus: got %q, want running", st)
|
|
}
|
|
if _, err := d.LocalStatus(context.Background(), "missing"); err == nil {
|
|
t.Error("LocalStatus: expected error for missing job, got nil")
|
|
}
|
|
}
|
|
|
|
func TestDispatcher_LocalSubmit_NilExecutor(t *testing.T) {
|
|
d := NewDispatcher(nil, nil, NewPeerRegistry(), nil)
|
|
if _, err := d.LocalSubmit(context.Background(), []byte(`{}`)); err == nil {
|
|
t.Error("LocalSubmit with nil executor: expected error, got nil")
|
|
}
|
|
if _, err := d.LocalStatus(context.Background(), "x"); err == nil {
|
|
t.Error("LocalStatus with nil executor: expected error, got nil")
|
|
}
|
|
}
|
|
|
|
func TestParseInlineSpec(t *testing.T) {
|
|
spec, err := parseInlineSpec([]byte(`{"cpu_millicores":500,"memory_mib":256,"disk_mib":128}`))
|
|
if err != nil {
|
|
t.Fatalf("parseInlineSpec: %v", err)
|
|
}
|
|
if spec.CPUMillicores != 500 || spec.MemoryMiB != 256 || spec.DiskMiB != 128 {
|
|
t.Errorf("parseInlineSpec: got %+v, want cpu=500 mem=256 disk=128", spec)
|
|
}
|
|
if _, err := parseInlineSpec([]byte(`{bad json`)); err == nil {
|
|
t.Fatal("parseInlineSpec: expected error for malformed JSON, got nil")
|
|
}
|
|
}
|
|
|
|
func TestDispatcher_Submit_BadSpec(t *testing.T) {
|
|
d, _, cleanup := newTestDispatcher(t, &mockExecutor{})
|
|
defer cleanup()
|
|
_, _, err := d.Submit(context.Background(), "", []byte(`{bad json`), "")
|
|
if err == nil {
|
|
t.Fatal("expected error for malformed spec")
|
|
}
|
|
}
|
|
|
|
func TestDispatcher_Submit_ExplicitTargetNoPeerRegistry(t *testing.T) {
|
|
d := NewDispatcher(nil, nil, nil, &mockExecutor{})
|
|
_, _, err := d.Submit(context.Background(), "nodeX", []byte(`{"cpu_millicores":100,"memory_mib":64,"disk_mib":64}`), "")
|
|
if err == nil {
|
|
t.Fatal("expected error for explicit target with no peer registry")
|
|
}
|
|
}
|
|
|
|
func TestDispatcher_Submit_ExplicitTargetPeerNotFound(t *testing.T) {
|
|
d, _, cleanup := newTestDispatcher(t, &mockExecutor{})
|
|
defer cleanup()
|
|
_, _, err := d.Submit(context.Background(), "ghost", []byte(`{"cpu_millicores":100,"memory_mib":64,"disk_mib":64}`), "")
|
|
if err == nil {
|
|
t.Fatal("expected error for target not in registry")
|
|
}
|
|
}
|
|
|
|
func TestDispatcher_Submit_PickPeerMissingCA(t *testing.T) {
|
|
exec := &mockExecutor{}
|
|
d, capRepo, cleanup := newTestDispatcher(t, exec)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
if err := capRepo.Upsert(ctx, &store.NodeCapacity{
|
|
NodeID: "self",
|
|
CPUMillicores: 0,
|
|
MemoryMiB: 0,
|
|
DiskMiB: 0,
|
|
}); err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
if err := d.peers.Add(&Peer{
|
|
NodeID: "peer-1",
|
|
Address: "127.0.0.1:1",
|
|
Capacity: &store.NodeCapacity{NodeID: "peer-1", CPUMillicores: 4000, MemoryMiB: 4096, DiskMiB: 4096},
|
|
}); err != nil {
|
|
t.Fatalf("Add peer: %v", err)
|
|
}
|
|
spec := []byte(`{"cpu_millicores":100,"memory_mib":64,"disk_mib":64}`)
|
|
_, _, err := d.Submit(ctx, "", spec, "idem-peer-1")
|
|
if err == nil {
|
|
t.Fatal("expected error (peer missing CA/servername)")
|
|
}
|
|
}
|
|
|
|
func TestDispatcher_Submit_NoPeerRegistry(t *testing.T) {
|
|
d := NewDispatcher(nil, nil, nil, &mockExecutor{})
|
|
spec := []byte(`{"cpu_millicores":1000,"memory_mib":1024,"disk_mib":1024}`)
|
|
_, _, err := d.Submit(context.Background(), "", spec, "")
|
|
if err == nil {
|
|
t.Fatal("expected error for no peer registry and no capacity repo")
|
|
}
|
|
}
|
|
|
|
func TestDispatcher_Submit_NilCapacityFallsThrough(t *testing.T) {
|
|
exec := &mockExecutor{}
|
|
d := NewDispatcher(nil, nil, NewPeerRegistry(), exec)
|
|
spec := []byte(`{"cpu_millicores":1000,"memory_mib":1024,"disk_mib":1024}`)
|
|
_, _, err := d.Submit(context.Background(), "", spec, "")
|
|
if err == nil {
|
|
t.Fatal("expected error when capacity repo is nil and no peers")
|
|
}
|
|
}
|
|
|
|
func TestDispatcher_Submit_AllPeersFailsPickNode(t *testing.T) {
|
|
exec := &mockExecutor{}
|
|
d, capRepo, cleanup := newTestDispatcher(t, exec)
|
|
defer cleanup()
|
|
ctx := context.Background()
|
|
if err := capRepo.Upsert(ctx, &store.NodeCapacity{
|
|
NodeID: "self",
|
|
CPUMillicores: 0,
|
|
MemoryMiB: 0,
|
|
DiskMiB: 0,
|
|
}); err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
if err := d.peers.Add(&Peer{
|
|
NodeID: "peer-tiny",
|
|
Address: "127.0.0.1:1",
|
|
Capacity: &store.NodeCapacity{NodeID: "peer-tiny", CPUMillicores: 10, MemoryMiB: 10, DiskMiB: 10},
|
|
}); err != nil {
|
|
t.Fatalf("Add peer: %v", err)
|
|
}
|
|
spec := []byte(`{"cpu_millicores":1000,"memory_mib":1024,"disk_mib":1024}`)
|
|
_, _, err := d.Submit(ctx, "", spec, "")
|
|
if err == nil {
|
|
t.Fatal("expected error when no peer can fit")
|
|
}
|
|
}
|