Add modules.Registry and config.Config fields to Server struct. Create registry in main.go, load state from YAML+DB, log all module states at startup. Conditionally start job/runner sweeper goroutines only when the jobs module is enabled. Update all 5 test files to pass registry to NewServer. Ref #95, #96
341 lines
8.7 KiB
Go
341 lines
8.7 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/kindredsystems/silo/internal/db"
|
|
"github.com/kindredsystems/silo/internal/modules"
|
|
"github.com/kindredsystems/silo/internal/schema"
|
|
"github.com/kindredsystems/silo/internal/testutil"
|
|
"github.com/rs/zerolog"
|
|
)
|
|
|
|
func newJobTestServer(t *testing.T) *Server {
|
|
t.Helper()
|
|
pool := testutil.MustConnectTestPool(t)
|
|
database := db.NewFromPool(pool)
|
|
broker := NewBroker(zerolog.Nop())
|
|
state := NewServerState(zerolog.Nop(), nil, broker)
|
|
return NewServer(
|
|
zerolog.Nop(),
|
|
database,
|
|
map[string]*schema.Schema{},
|
|
"",
|
|
nil, nil, nil, nil, nil,
|
|
broker, state,
|
|
nil, "",
|
|
modules.NewRegistry(), nil,
|
|
)
|
|
}
|
|
|
|
func newJobRouter(s *Server) http.Handler {
|
|
r := chi.NewRouter()
|
|
r.Route("/api/jobs", func(r chi.Router) {
|
|
r.Get("/", s.HandleListJobs)
|
|
r.Get("/{jobID}", s.HandleGetJob)
|
|
r.Get("/{jobID}/logs", s.HandleGetJobLogs)
|
|
r.Post("/", s.HandleCreateJob)
|
|
r.Post("/{jobID}/cancel", s.HandleCancelJob)
|
|
})
|
|
r.Route("/api/job-definitions", func(r chi.Router) {
|
|
r.Get("/", s.HandleListJobDefinitions)
|
|
r.Get("/{name}", s.HandleGetJobDefinition)
|
|
})
|
|
r.Route("/api/runners", func(r chi.Router) {
|
|
r.Get("/", s.HandleListRunners)
|
|
r.Post("/", s.HandleRegisterRunner)
|
|
r.Delete("/{runnerID}", s.HandleDeleteRunner)
|
|
})
|
|
return r
|
|
}
|
|
|
|
func seedJobDefinition(t *testing.T, s *Server) *db.JobDefinitionRecord {
|
|
t.Helper()
|
|
rec := &db.JobDefinitionRecord{
|
|
Name: "test-validate",
|
|
Version: 1,
|
|
TriggerType: "manual",
|
|
ScopeType: "item",
|
|
ComputeType: "validate",
|
|
RunnerTags: []string{"create"},
|
|
TimeoutSeconds: 300,
|
|
MaxRetries: 1,
|
|
Priority: 100,
|
|
Definition: map[string]any{"compute": map[string]any{"command": "create-validate"}},
|
|
Enabled: true,
|
|
}
|
|
if err := s.jobs.UpsertDefinition(context.Background(), rec); err != nil {
|
|
t.Fatalf("seeding job definition: %v", err)
|
|
}
|
|
return rec
|
|
}
|
|
|
|
func TestHandleListJobDefinitions(t *testing.T) {
|
|
s := newJobTestServer(t)
|
|
r := newJobRouter(s)
|
|
|
|
seedJobDefinition(t, s)
|
|
|
|
req := httptest.NewRequest("GET", "/api/job-definitions", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var defs []map[string]any
|
|
json.Unmarshal(w.Body.Bytes(), &defs)
|
|
if len(defs) == 0 {
|
|
t.Error("expected at least one definition")
|
|
}
|
|
}
|
|
|
|
func TestHandleGetJobDefinition(t *testing.T) {
|
|
s := newJobTestServer(t)
|
|
r := newJobRouter(s)
|
|
|
|
seedJobDefinition(t, s)
|
|
|
|
req := httptest.NewRequest("GET", "/api/job-definitions/test-validate", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var def map[string]any
|
|
json.Unmarshal(w.Body.Bytes(), &def)
|
|
if def["name"] != "test-validate" {
|
|
t.Errorf("expected name test-validate, got %v", def["name"])
|
|
}
|
|
}
|
|
|
|
func TestHandleCreateAndGetJob(t *testing.T) {
|
|
s := newJobTestServer(t)
|
|
r := newJobRouter(s)
|
|
|
|
seedJobDefinition(t, s)
|
|
|
|
// Create a job
|
|
body := `{"definition_name": "test-validate"}`
|
|
req := httptest.NewRequest("POST", "/api/jobs", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("create: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var job map[string]any
|
|
json.Unmarshal(w.Body.Bytes(), &job)
|
|
jobID := job["ID"].(string)
|
|
if jobID == "" {
|
|
t.Fatal("job ID is empty")
|
|
}
|
|
|
|
// Get the job
|
|
req2 := httptest.NewRequest("GET", "/api/jobs/"+jobID, nil)
|
|
w2 := httptest.NewRecorder()
|
|
r.ServeHTTP(w2, req2)
|
|
|
|
if w2.Code != http.StatusOK {
|
|
t.Fatalf("get: expected 200, got %d: %s", w2.Code, w2.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleCancelJob(t *testing.T) {
|
|
s := newJobTestServer(t)
|
|
r := newJobRouter(s)
|
|
|
|
seedJobDefinition(t, s)
|
|
|
|
// Create a job
|
|
body := `{"definition_name": "test-validate"}`
|
|
req := httptest.NewRequest("POST", "/api/jobs", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
var job map[string]any
|
|
json.Unmarshal(w.Body.Bytes(), &job)
|
|
jobID := job["ID"].(string)
|
|
|
|
// Cancel the job
|
|
req2 := httptest.NewRequest("POST", "/api/jobs/"+jobID+"/cancel", nil)
|
|
w2 := httptest.NewRecorder()
|
|
r.ServeHTTP(w2, req2)
|
|
|
|
if w2.Code != http.StatusOK {
|
|
t.Fatalf("cancel: expected 200, got %d: %s", w2.Code, w2.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleListJobs(t *testing.T) {
|
|
s := newJobTestServer(t)
|
|
r := newJobRouter(s)
|
|
|
|
seedJobDefinition(t, s)
|
|
|
|
// Create a job
|
|
body := `{"definition_name": "test-validate"}`
|
|
req := httptest.NewRequest("POST", "/api/jobs", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
// List jobs
|
|
req2 := httptest.NewRequest("GET", "/api/jobs", nil)
|
|
w2 := httptest.NewRecorder()
|
|
r.ServeHTTP(w2, req2)
|
|
|
|
if w2.Code != http.StatusOK {
|
|
t.Fatalf("list: expected 200, got %d: %s", w2.Code, w2.Body.String())
|
|
}
|
|
|
|
var jobs []map[string]any
|
|
json.Unmarshal(w2.Body.Bytes(), &jobs)
|
|
if len(jobs) == 0 {
|
|
t.Error("expected at least one job")
|
|
}
|
|
}
|
|
|
|
func TestHandleListJobs_FilterByStatus(t *testing.T) {
|
|
s := newJobTestServer(t)
|
|
r := newJobRouter(s)
|
|
|
|
seedJobDefinition(t, s)
|
|
|
|
// Create a job
|
|
body := `{"definition_name": "test-validate"}`
|
|
req := httptest.NewRequest("POST", "/api/jobs", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
// Filter by pending
|
|
req2 := httptest.NewRequest("GET", "/api/jobs?status=pending", nil)
|
|
w2 := httptest.NewRecorder()
|
|
r.ServeHTTP(w2, req2)
|
|
|
|
if w2.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w2.Code)
|
|
}
|
|
|
|
var jobs []map[string]any
|
|
json.Unmarshal(w2.Body.Bytes(), &jobs)
|
|
if len(jobs) == 0 {
|
|
t.Error("expected pending jobs")
|
|
}
|
|
|
|
// Filter by completed (should be empty)
|
|
req3 := httptest.NewRequest("GET", "/api/jobs?status=completed", nil)
|
|
w3 := httptest.NewRecorder()
|
|
r.ServeHTTP(w3, req3)
|
|
|
|
var completedJobs []map[string]any
|
|
json.Unmarshal(w3.Body.Bytes(), &completedJobs)
|
|
if len(completedJobs) != 0 {
|
|
t.Errorf("expected no completed jobs, got %d", len(completedJobs))
|
|
}
|
|
}
|
|
|
|
func TestHandleRegisterAndListRunners(t *testing.T) {
|
|
s := newJobTestServer(t)
|
|
r := newJobRouter(s)
|
|
|
|
// Register a runner
|
|
body := `{"name": "test-runner-1", "tags": ["create", "linux"]}`
|
|
req := httptest.NewRequest("POST", "/api/runners", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("register: expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
|
if resp["token"] == nil || resp["token"] == "" {
|
|
t.Error("expected a token in response")
|
|
}
|
|
if !strings.HasPrefix(resp["token"].(string), "silo_runner_") {
|
|
t.Errorf("expected token to start with silo_runner_, got %s", resp["token"])
|
|
}
|
|
|
|
// List runners
|
|
req2 := httptest.NewRequest("GET", "/api/runners", nil)
|
|
w2 := httptest.NewRecorder()
|
|
r.ServeHTTP(w2, req2)
|
|
|
|
if w2.Code != http.StatusOK {
|
|
t.Fatalf("list: expected 200, got %d", w2.Code)
|
|
}
|
|
|
|
var runners []map[string]any
|
|
json.Unmarshal(w2.Body.Bytes(), &runners)
|
|
if len(runners) == 0 {
|
|
t.Error("expected at least one runner")
|
|
}
|
|
// Token hash should not be exposed
|
|
for _, runner := range runners {
|
|
if runner["token_hash"] != nil {
|
|
t.Error("token_hash should not be in response")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleDeleteRunner(t *testing.T) {
|
|
s := newJobTestServer(t)
|
|
r := newJobRouter(s)
|
|
|
|
// Register a runner
|
|
body := `{"name": "test-runner-delete", "tags": ["create"]}`
|
|
req := httptest.NewRequest("POST", "/api/runners", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
var resp map[string]any
|
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
|
runnerID := resp["id"].(string)
|
|
|
|
// Delete the runner
|
|
req2 := httptest.NewRequest("DELETE", "/api/runners/"+runnerID, nil)
|
|
w2 := httptest.NewRecorder()
|
|
r.ServeHTTP(w2, req2)
|
|
|
|
if w2.Code != http.StatusNoContent {
|
|
t.Fatalf("delete: expected 204, got %d: %s", w2.Code, w2.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGenerateRunnerToken(t *testing.T) {
|
|
raw, hash, prefix := generateRunnerToken()
|
|
|
|
if !strings.HasPrefix(raw, "silo_runner_") {
|
|
t.Errorf("raw token should start with silo_runner_, got %s", raw[:20])
|
|
}
|
|
if len(hash) != 64 {
|
|
t.Errorf("hash should be 64 hex chars, got %d", len(hash))
|
|
}
|
|
if len(prefix) != 20 {
|
|
t.Errorf("prefix should be 20 chars, got %d: %s", len(prefix), prefix)
|
|
}
|
|
|
|
// Two tokens should be different
|
|
raw2, _, _ := generateRunnerToken()
|
|
if raw == raw2 {
|
|
t.Error("two generated tokens should be different")
|
|
}
|
|
}
|