diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cf9612..7fd390a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,12 +15,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: - go-version: '1.23' + go-version-file: 'go.mod' cache: true - name: Install dependencies @@ -47,12 +47,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22.10.0' cache: 'npm' - name: Install dependencies diff --git a/oracle/oracle_test.go b/oracle/oracle_test.go new file mode 100644 index 0000000..d947dd1 --- /dev/null +++ b/oracle/oracle_test.go @@ -0,0 +1,75 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestOracleSetGetAndCopySemantics(t *testing.T) { + o := NewOracle() + o.SetPrice("node-1", 0.05, "mock") + + entry, ok := o.GetPrice("node-1") + if !ok { + t.Fatal("expected price entry") + } + entry.Price = 999 + + entry2, ok := o.GetPrice("node-1") + if !ok { + t.Fatal("expected price entry") + } + if entry2.Price == 999 { + t.Fatal("expected GetPrice to return a copy") + } +} + +func TestHandleGetPriceFoundAndNotFound(t *testing.T) { + o := NewOracle() + o.SetPrice("node-1", 0.05, "mock") + + foundReq := httptest.NewRequest(http.MethodGet, "/price/node-1", nil) + foundReq.SetPathValue("providerID", "node-1") + foundRec := httptest.NewRecorder() + o.HandleGetPrice(foundRec, foundReq) + if foundRec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", foundRec.Code) + } + + var payload PriceEntry + if err := json.Unmarshal(foundRec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload.ProviderID != "node-1" { + t.Fatalf("expected provider id node-1, got %s", payload.ProviderID) + } + + missReq := httptest.NewRequest(http.MethodGet, "/price/missing", nil) + missReq.SetPathValue("providerID", "missing") + missRec := httptest.NewRecorder() + o.HandleGetPrice(missRec, missReq) + if missRec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", missRec.Code) + } +} + +func TestHandleGetAllPricesReturnsArray(t *testing.T) { + o := NewOracle() + + req := httptest.NewRequest(http.MethodGet, "/prices", nil) + rec := httptest.NewRecorder() + o.HandleGetAllPrices(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + var entries []PriceEntry + if err := json.Unmarshal(rec.Body.Bytes(), &entries); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(entries) != 0 { + t.Fatalf("expected empty array, got %d entries", len(entries)) + } +} diff --git a/oracle/pricing_test.go b/oracle/pricing_test.go new file mode 100644 index 0000000..502b35c --- /dev/null +++ b/oracle/pricing_test.go @@ -0,0 +1,37 @@ +package main + +import "testing" + +func TestFetchAllPricesContainsExpectedProvidersWithPositiveValues(t *testing.T) { + prices := FetchAllPrices() + if len(prices) == 0 { + t.Fatal("expected non-empty price map") + } + + required := []string{"aws-t3-medium", "gcp-n1-standard-2", "node-1"} + for _, id := range required { + p, ok := prices[id] + if !ok { + t.Fatalf("expected provider %s in feed", id) + } + if p.PricePerHour <= 0 { + t.Fatalf("expected positive price for %s, got %f", id, p.PricePerHour) + } + if p.Source == "" { + t.Fatalf("expected non-empty source for %s", id) + } + } +} + +func TestJitterIsWithinFivePercent(t *testing.T) { + base := 100.0 + min := base * 0.95 + max := base * 1.05 + + for i := 0; i < 1000; i++ { + v := jitter(base) + if v < min || v > max { + t.Fatalf("jitter value %f out of bounds [%f, %f]", v, min, max) + } + } +} diff --git a/scheduler/provider_manager.go b/scheduler/provider_manager.go index 2b15db2..ad5e399 100644 --- a/scheduler/provider_manager.go +++ b/scheduler/provider_manager.go @@ -71,6 +71,20 @@ func (pm *ProviderManager) Get(id string) (*Provider, bool) { return ©, true } +// GetCount returns the number of active providers. +func (pm *ProviderManager) GetCount() int { + pm.mu.RLock() + defer pm.mu.RUnlock() + + count := 0 + for _, p := range pm.providers { + if p.Active { + count++ + } + } + return count +} + // UpdateReputation adjusts a provider's reputation score (clamped 0-100). func (pm *ProviderManager) UpdateReputation(id string, delta int) { pm.mu.Lock() diff --git a/scheduler/provider_manager_test.go b/scheduler/provider_manager_test.go new file mode 100644 index 0000000..1b2dd5a --- /dev/null +++ b/scheduler/provider_manager_test.go @@ -0,0 +1,115 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestProviderManagerRegisterGetAndCopySemantics(t *testing.T) { + pm := NewProviderManager() + pm.Register(Provider{ID: "p1", CPU: 4, MemoryMB: 8192, PricePerHour: 0.1, Reputation: 80}) + + if pm.GetCount() != 1 { + t.Fatalf("expected 1 active provider, got %d", pm.GetCount()) + } + + got, ok := pm.Get("p1") + if !ok { + t.Fatal("expected provider to exist") + } + got.CPU = 999 + + gotAgain, ok := pm.Get("p1") + if !ok { + t.Fatal("expected provider to exist") + } + if gotAgain.CPU == 999 { + t.Fatal("expected Get to return a copy, not original pointer") + } + + active := pm.GetActive() + if len(active) != 1 { + t.Fatalf("expected one active provider, got %d", len(active)) + } + active[0].MemoryMB = 1 + + active2 := pm.GetActive() + if active2[0].MemoryMB == 1 { + t.Fatal("expected GetActive to return copies, not original pointers") + } +} + +func TestProviderManagerDeregisterAndUpdateReputationClamp(t *testing.T) { + pm := NewProviderManager() + pm.Register(Provider{ID: "p1", CPU: 2, MemoryMB: 2048, PricePerHour: 0.05, Reputation: 50}) + + if !pm.Deregister("p1") { + t.Fatal("expected deregister to return true for existing provider") + } + if pm.GetCount() != 0 { + t.Fatalf("expected no active providers, got %d", pm.GetCount()) + } + if pm.Deregister("missing") { + t.Fatal("expected deregister to return false for missing provider") + } + + pm.UpdateReputation("p1", 1000) + p, _ := pm.Get("p1") + if p.Reputation != 100 { + t.Fatalf("expected reputation clamp to 100, got %d", p.Reputation) + } + + pm.UpdateReputation("p1", -1000) + p, _ = pm.Get("p1") + if p.Reputation != 0 { + t.Fatalf("expected reputation clamp to 0, got %d", p.Reputation) + } +} + +func TestHandleRegisterProviderValidationAndSuccess(t *testing.T) { + pm := NewProviderManager() + + invalidReq := httptest.NewRequest(http.MethodPost, "/providers/register", strings.NewReader(`{"id":"","cpu":0,"memoryMB":0}`)) + invalidRec := httptest.NewRecorder() + pm.HandleRegisterProvider(invalidRec, invalidReq) + if invalidRec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for invalid payload, got %d", invalidRec.Code) + } + + validReq := httptest.NewRequest(http.MethodPost, "/providers/register", strings.NewReader(`{"id":"p1","cpu":4,"memoryMB":8192,"pricePerHour":0.1}`)) + validRec := httptest.NewRecorder() + pm.HandleRegisterProvider(validRec, validReq) + if validRec.Code != http.StatusCreated { + t.Fatalf("expected 201 for valid payload, got %d", validRec.Code) + } + + var body map[string]string + if err := json.Unmarshal(validRec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body["id"] != "p1" { + t.Fatalf("expected id p1, got %q", body["id"]) + } +} + +func TestHandleListProvidersReturnsEmptyArrayNotNull(t *testing.T) { + pm := NewProviderManager() + req := httptest.NewRequest(http.MethodGet, "/providers", nil) + rec := httptest.NewRecorder() + + pm.HandleListProviders(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + var providers []Provider + if err := json.Unmarshal(rec.Body.Bytes(), &providers); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(providers) != 0 { + t.Fatalf("expected empty provider list, got %d", len(providers)) + } +} diff --git a/scheduler/scheduler_test.go b/scheduler/scheduler_test.go new file mode 100644 index 0000000..081e7ee --- /dev/null +++ b/scheduler/scheduler_test.go @@ -0,0 +1,139 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSubmitJobAssignsBestEligibleProvider(t *testing.T) { + oracle := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]float64{"price": 0.2}) + })) + defer oracle.Close() + + pm := NewProviderManager() + pm.Register(Provider{ID: "p1", CPU: 8, MemoryMB: 8192, PricePerHour: 0.3, Reputation: 80, Address: "0xabc"}) + pm.Register(Provider{ID: "p2", CPU: 16, MemoryMB: 32768, PricePerHour: 0.25, Reputation: 95, Address: "0xdef"}) + + s := NewScheduler(pm, oracle.URL, nil) + job, err := s.SubmitJob(JobSubmitRequest{ + ClientID: "c1", + CPURequired: 4, + MemoryMB: 2048, + MaxDurationSecs: 3600, + MaxPricePerHour: 1.0, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if job.Status != JobStatusAssigned { + t.Fatalf("expected assigned status, got %s", job.Status) + } + if job.AssignedProvider == "" { + t.Fatal("expected assigned provider") + } +} + +func TestSubmitJobWithNoEligibleProviderReturnsPending(t *testing.T) { + pm := NewProviderManager() + pm.Register(Provider{ID: "small", CPU: 1, MemoryMB: 512, PricePerHour: 10, Reputation: 90}) + + s := NewScheduler(pm, "http://127.0.0.1:0", nil) + job, err := s.SubmitJob(JobSubmitRequest{ + ClientID: "c1", + CPURequired: 8, + MemoryMB: 16384, + MaxDurationSecs: 3600, + MaxPricePerHour: 1.0, + }) + if err == nil { + t.Fatal("expected error when no provider is eligible") + } + if job.Status != JobStatusPending { + t.Fatalf("expected pending status, got %s", job.Status) + } +} + +func TestHandleSubmitJobValidationAndAccepted(t *testing.T) { + pm := NewProviderManager() + s := NewScheduler(pm, "http://127.0.0.1:0", nil) + + badReq := httptest.NewRequest(http.MethodPost, "/jobs", strings.NewReader(`{"cpuRequired":0,"memoryMB":0}`)) + badRec := httptest.NewRecorder() + s.HandleSubmitJob(badRec, badReq) + if badRec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for invalid payload, got %d", badRec.Code) + } + + validReq := httptest.NewRequest(http.MethodPost, "/jobs", strings.NewReader(`{"clientId":"c1","cpuRequired":2,"memoryMB":2048,"maxDurationSecs":60,"maxPricePerHour":1}`)) + validRec := httptest.NewRecorder() + s.HandleSubmitJob(validRec, validReq) + if validRec.Code != http.StatusAccepted { + t.Fatalf("expected 202 when no providers can be assigned, got %d", validRec.Code) + } +} + +func TestHandleGetJobFoundAndNotFound(t *testing.T) { + pm := NewProviderManager() + pm.Register(Provider{ID: "p1", CPU: 8, MemoryMB: 8192, PricePerHour: 0.2, Reputation: 90}) + + s := NewScheduler(pm, "http://127.0.0.1:0", nil) + job, err := s.SubmitJob(JobSubmitRequest{ + ClientID: "c1", + CPURequired: 1, + MemoryMB: 512, + MaxDurationSecs: 60, + MaxPricePerHour: 1, + }) + if err != nil { + t.Fatalf("submit job: %v", err) + } + + foundReq := httptest.NewRequest(http.MethodGet, "/jobs/"+job.ID, nil) + foundReq.SetPathValue("id", job.ID) + foundRec := httptest.NewRecorder() + s.HandleGetJob(foundRec, foundReq) + if foundRec.Code != http.StatusOK { + t.Fatalf("expected 200 for existing job, got %d", foundRec.Code) + } + + notFoundReq := httptest.NewRequest(http.MethodGet, "/jobs/missing", nil) + notFoundReq.SetPathValue("id", "missing") + notFoundRec := httptest.NewRecorder() + s.HandleGetJob(notFoundRec, notFoundReq) + if notFoundRec.Code != http.StatusNotFound { + t.Fatalf("expected 404 for missing job, got %d", notFoundRec.Code) + } +} + +func TestHandleMetricsIncludesJobAndProviderCounts(t *testing.T) { + pm := NewProviderManager() + pm.Register(Provider{ID: "p1", CPU: 4, MemoryMB: 4096, PricePerHour: 0.2, Reputation: 80}) + pm.Register(Provider{ID: "p2", CPU: 4, MemoryMB: 4096, PricePerHour: 0.3, Reputation: 70}) + _ = pm.Deregister("p2") + + s := NewScheduler(pm, "http://127.0.0.1:0", nil) + s.jobs["j1"] = &Job{ID: "j1", Status: JobStatusPending} + s.jobs["j2"] = &Job{ID: "j2", Status: JobStatusAssigned} + + req := httptest.NewRequest(http.MethodGet, "/metrics", nil) + rec := httptest.NewRecorder() + s.HandleMetrics(rec, req) + + body := rec.Body.String() + if !strings.Contains(body, "nimbusx_scheduler_total_jobs_total 2") { + t.Fatalf("expected total job metric in response, got:\n%s", body) + } + if !strings.Contains(body, "nimbusx_scheduler_pending_jobs 1") { + t.Fatalf("expected pending metric in response, got:\n%s", body) + } + if !strings.Contains(body, "nimbusx_scheduler_assigned_jobs 1") { + t.Fatalf("expected assigned metric in response, got:\n%s", body) + } + if !strings.Contains(body, "nimbusx_scheduler_active_providers 1") { + t.Fatalf("expected active provider metric in response, got:\n%s", body) + } +} diff --git a/scheduler/scoring_test.go b/scheduler/scoring_test.go new file mode 100644 index 0000000..5074336 --- /dev/null +++ b/scheduler/scoring_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "math" + "testing" +) + +func TestScoreProvidersUsesOracleOverrideAndRanks(t *testing.T) { + job := &Job{CPURequired: 4, MemoryMB: 4096, MaxPricePerHour: 1.0} + p1 := &Provider{ID: "p1", CPU: 8, MemoryMB: 8192, PricePerHour: 0.9, Reputation: 90} + p2 := &Provider{ID: "p2", CPU: 8, MemoryMB: 8192, PricePerHour: 0.5, Reputation: 80} + + oracle := func(id string) float64 { + if id == "p1" { + return 0.1 + } + return 0 + } + + results := ScoreProviders([]*Provider{p1, p2}, job, oracle) + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + if results[0].Provider.ID != "p1" { + t.Fatalf("expected p1 to score higher with oracle price override, got %s", results[0].Provider.ID) + } +} + +func TestComputeScoreClampsAndExpectedValue(t *testing.T) { + job := &Job{CPURequired: 4, MemoryMB: 4096, MaxPricePerHour: 1.0} + p := &Provider{ID: "p1", CPU: 8, MemoryMB: 8192, PricePerHour: 2.0, Reputation: 100} + + // costScore clamps to 0 when provider price > max. + // reputationScore = 1.0 + // headroom = ((8-4)/8 + (8192-4096)/8192) / 2 = 0.5 + // final = 0.40*0 + 0.35*1 + 0.25*0.5 = 0.475 + got := computeScore(p, job, nil) + if math.Abs(got-0.475) > 1e-9 { + t.Fatalf("expected score 0.475, got %f", got) + } +}