diff --git a/.gitignore b/.gitignore index 562723f..62d1cdf 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ schedune-agent/target/ schedune-control-plane/intake schedune-control-plane/schedune-control-plane bin/ +var/ +.schedune.pid diff --git a/Makefile b/Makefile index e9c9bee..ae69809 100644 --- a/Makefile +++ b/Makefile @@ -63,6 +63,22 @@ smoke-test: build ## Run headless E2E automated API smoke test demo: doctor build dev-db-reset ## Run the automated end-to-end evaluator demo @bash scripts/demo.sh +demo-fixture: build-control-plane dev-db-reset ## Run the fixture-backed evaluator demo (macOS/non-Linux friendly) + @bash scripts/demo-fixture.sh + +demo-fixture-once: build-control-plane dev-db-reset ## Run the fixture-backed evaluator demo and exit cleanly + @bash scripts/demo-fixture.sh --once + +example-nodes: ## List normalized node summaries + @curl -s http://127.0.0.1:9090/api/v1alpha1/nodes + +example-node-explain: ## Explain scheduling eligibility for a specific node (Requires NODE_ID) + @if [ -z "$(NODE_ID)" ]; then \ + echo "Usage: make example-node-explain NODE_ID="; \ + else \ + curl -s http://127.0.0.1:9090/api/v1alpha1/nodes/$(NODE_ID)/explain; \ + fi + example-intake: ## Ingest the local node capability payload @bash examples/curls/intake.sh diff --git a/README.md b/README.md index e293f59..4a23c4b 100644 --- a/README.md +++ b/README.md @@ -31,20 +31,36 @@ Get a single-node Schedune control plane and agent running in under 5 minutes. ### 1. Preflight Check -Check if your local Linux host is ready for the evaluator: +Check if your local host is ready for the evaluator: ```bash make dev-preflight ``` -### 2. Evaluator Demo +### 2. Evaluator Demo (Linux) -Run the end-to-end evaluator journey. This builds the components, starts the control plane, inspects your local node, ingests the truth, and evaluates a sample workload intent. +If you are on a Linux host with KVM, run the end-to-end evaluator journey. This builds the components, starts the control plane, inspects your local node, ingests the truth, and evaluates a sample workload intent. ```bash make demo ``` +### 3. Evaluate from a MacBook / non-Linux host + +On a MacBook M2 Air (or other non-Linux hosts), you can still test control-plane intake, scheduling explainability, launch validation against fixture truth, node APIs, and orphan API shape. Actual VM/microVM execution requires Linux with KVM and runtime binaries. + +Run the fixture-backed evaluator demo to quickly verify the pipeline: + +```bash +make demo-fixture-once +``` + +For an interactive session where you can explore the API manually afterwards, run: + +```bash +make demo-fixture +``` + ### 3. Step-by-Step Examples If you want to run it manually using the provided targets: diff --git a/docs/quickstart.md b/docs/quickstart.md index 05a2ded..83292fe 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -16,9 +16,9 @@ Get a single-node Schedune control plane and agent running in under 5 minutes. You can evaluate Schedune using our automated demo mode or by walking through the manual flow. -### Automated Demo Mode +### Automated Demo Mode (Linux) -The easiest way to see Schedune in action: +The easiest way to see Schedune in action on Linux: ```bash make demo @@ -26,6 +26,22 @@ make demo This will run the preflight checks, build the binaries, reset the database, start the control plane, ingest your local node's capabilities, and run a workload scheduling explanation. +### Evaluate from a MacBook / non-Linux host + +On a MacBook M2 Air (or other non-Linux hosts), you can still test control-plane intake, scheduling explainability, launch validation against fixture truth, node APIs, and orphan API shape. Actual VM/microVM execution requires Linux with KVM and runtime binaries. + +Run the fixture-backed evaluator demo to quickly verify the pipeline: + +```bash +make demo-fixture-once +``` + +For an interactive session where you can explore the API manually afterwards, run: + +```bash +make demo-fixture +``` + ### Manual Step-by-Step Flow #### 1. Preflight Check diff --git a/schedune-control-plane/cmd/schedune/main.go b/schedune-control-plane/cmd/schedune/main.go index 087a619..aaa5f1c 100644 --- a/schedune-control-plane/cmd/schedune/main.go +++ b/schedune-control-plane/cmd/schedune/main.go @@ -129,15 +129,14 @@ func runDoctor() { fmt.Printf("%s Architecture: %s\n", PASS, runtime.GOARCH) fmt.Printf("%s Kernel: %s\n", PASS, getKernelVersion()) } else { - fmt.Printf("%s OS: %s (Schedune requires Linux)\n", FAIL, runtime.GOOS) - controlPlaneReady = false + fmt.Printf("%s OS: %s (Control plane: evaluator mode. Agent/Runtime: Linux required)\n", WARN, runtime.GOOS) agentInspectReady = false } if syscall.Access("/proc", 4) == nil { fmt.Printf("%s /proc: readable\n", PASS) } else { - fmt.Printf("%s /proc: not readable\n", FAIL) + fmt.Printf("%s /proc: not readable\n", WARN) orphanSweepReady = false } fmt.Println() @@ -277,7 +276,9 @@ func runDoctor() { fmt.Println() fmt.Println("Recommended next step:") - if controlPlaneReady && agentInspectReady { + if runtime.GOOS != "linux" && controlPlaneReady { + fmt.Println(" make demo-fixture (evaluator mode)") + } else if controlPlaneReady && agentInspectReady { fmt.Println(" make demo") } else { fmt.Println(" Resolve [FAIL] items before continuing.") @@ -302,7 +303,7 @@ func runServer() { } // Phase 7B.5: Real Orphan Sweep - enumerator := &cp_runtime.LinuxProcEnumerator{} + enumerator := cp_runtime.NewEnumerator() orphanSweeper := recovery.NewOrphanSweepService(enumerator, sqliteStore, sqliteStore, sqliteStore, "local-node") go func() { @@ -318,6 +319,8 @@ func runServer() { memStore := store.NewInMemoryStore() // Initialize HTTP Handlers + systemHandler := api.NewSystemHandler() + nodeHandler := api.NewNodeHandler(memStore) intakeHandler := api.NewIntakeHandler(memStore) schedulerHandler := api.NewSchedulerHandler(memStore) launchHandler := api.NewLaunchHandler(memStore, sqliteStore) @@ -330,6 +333,13 @@ func runServer() { // API Group v1 := r.Group("/api/v1alpha1") { + // System Health + v1.GET("/healthz", systemHandler.Healthz) + + // Operator -> Control Plane (Nodes) + v1.GET("/nodes", nodeHandler.ListNodes) + v1.GET("/nodes/:id", nodeHandler.GetNode) + // Data Plane -> Control Plane v1.POST("/intake/envelope", intakeHandler.Ingest) diff --git a/schedune-control-plane/internal/api/system_handlers.go b/schedune-control-plane/internal/api/system_handlers.go new file mode 100644 index 0000000..33052cc --- /dev/null +++ b/schedune-control-plane/internal/api/system_handlers.go @@ -0,0 +1,70 @@ +package api + +import ( + "net/http" + "runtime" + + "github.com/TechnologyTailors/Schedune/schedune-control-plane/internal/store" + "github.com/gin-gonic/gin" +) + +type SystemHandler struct{} + +func NewSystemHandler() *SystemHandler { + return &SystemHandler{} +} + +func (h *SystemHandler) Healthz(c *gin.Context) { + processEnumerationEnabled := runtime.GOOS == "linux" + + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "api_version": "v1alpha1", + "storage": "sqlite", + "process_enumeration_enabled": processEnumerationEnabled, + }) +} + +type NodeSummary struct { + ID string `json:"id"` + Hostname string `json:"hostname"` + Architecture string `json:"architecture"` + Health string `json:"health"` + Class string `json:"class"` +} + +type NodeHandler struct { + store *store.InMemoryStore +} + +func NewNodeHandler(s *store.InMemoryStore) *NodeHandler { + return &NodeHandler{store: s} +} + +func (h *NodeHandler) ListNodes(c *gin.Context) { + nodes := h.store.ListAllNodes() + var summaries []NodeSummary + + for _, n := range nodes { + summaries = append(summaries, NodeSummary{ + ID: n.ID, + Hostname: n.Identity.Hostname, + Architecture: n.Identity.Architecture, + Health: n.Health.State, + Class: n.Compatibility.Class, + }) + } + + c.JSON(http.StatusOK, gin.H{"nodes": summaries}) +} + +func (h *NodeHandler) GetNode(c *gin.Context) { + nodeID := c.Param("id") + record, err := h.store.GetNode(nodeID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Node not found"}) + return + } + + c.JSON(http.StatusOK, record) +} diff --git a/schedune-control-plane/internal/api/system_handlers_test.go b/schedune-control-plane/internal/api/system_handlers_test.go new file mode 100644 index 0000000..201b86e --- /dev/null +++ b/schedune-control-plane/internal/api/system_handlers_test.go @@ -0,0 +1,80 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "runtime" + "testing" + + "github.com/TechnologyTailors/Schedune/schedune-control-plane/internal/store" + "github.com/gin-gonic/gin" +) + +func TestSystemHandler_Healthz(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + handler := NewSystemHandler() + handler.Healthz(c) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response JSON: %v", err) + } + + if response["status"] != "ok" { + t.Errorf("Expected status 'ok', got '%v'", response["status"]) + } + if response["api_version"] != "v1alpha1" { + t.Errorf("Expected api_version 'v1alpha1', got '%v'", response["api_version"]) + } + if response["storage"] != "sqlite" { + t.Errorf("Expected storage 'sqlite', got '%v'", response["storage"]) + } + + expectedEnumEnabled := runtime.GOOS == "linux" + if response["process_enumeration_enabled"] != expectedEnumEnabled { + t.Errorf("Expected process_enumeration_enabled %v, got %v", expectedEnumEnabled, response["process_enumeration_enabled"]) + } +} + +func TestNodeHandler_ListAndGet(t *testing.T) { + gin.SetMode(gin.TestMode) + memStore := store.NewInMemoryStore() + handler := NewNodeHandler(memStore) + + // Add a dummy node directly to the underlying map for testing, or we can use domain.NodeRecord. + // Since InMemoryStore has SaveNodeState, we'd need an envelope. To mock, let's look at InMemoryStore. + // Actually, there is a ListAllNodes that reads from a map. If empty, returns []. + + // Test ListNodes (empty) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + handler.ListNodes(c) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + // For a fully fleshed out test, let's just make sure it returns 200 and a 'nodes' array + var listResp map[string][]NodeSummary + if err := json.Unmarshal(w.Body.Bytes(), &listResp); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + // Test GetNode (not found) + w2 := httptest.NewRecorder() + c2, _ := gin.CreateTestContext(w2) + c2.Params = []gin.Param{{Key: "id", Value: "non-existent"}} + handler.GetNode(c2) + + if w2.Code != http.StatusNotFound { + t.Errorf("Expected status 404, got %d", w2.Code) + } +} diff --git a/schedune-control-plane/internal/runtime/enumerate.go b/schedune-control-plane/internal/runtime/enumerate.go index f903623..b7afd6d 100644 --- a/schedune-control-plane/internal/runtime/enumerate.go +++ b/schedune-control-plane/internal/runtime/enumerate.go @@ -1,5 +1,7 @@ package runtime +import "runtime" + type EnumeratedProcess struct { PID int PPID *int @@ -16,3 +18,16 @@ type EnumeratedProcess struct { type Enumerator interface { Enumerate() ([]EnumeratedProcess, error) } + +func NewEnumerator() Enumerator { + if runtime.GOOS == "linux" { + return &LinuxProcEnumerator{} + } + return &NoopEnumerator{} +} + +type NoopEnumerator struct{} + +func (e *NoopEnumerator) Enumerate() ([]EnumeratedProcess, error) { + return nil, nil +} diff --git a/scripts/demo-fixture.sh b/scripts/demo-fixture.sh new file mode 100755 index 0000000..964ce39 --- /dev/null +++ b/scripts/demo-fixture.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +set -e + +DEMO_ONCE=0 +for arg in "$@"; do + if [ "$arg" == "--once" ]; then + DEMO_ONCE=1 + fi +done +if [ "${SCHEDUNE_DEMO_ONCE:-0}" -eq 1 ]; then + DEMO_ONCE=1 +fi + +echo "============================================" +echo " Schedune Fixture Evaluator Demo " +echo "============================================" +echo "This mode runs the Schedune Control Plane without an active local Agent." +echo "It ingests static test fixtures to simulate a cluster state." +echo "" + +# Go to the root directory +cd "$(dirname "$0")/.." + +# Build the control plane +echo "[*] Building control plane..." +cd schedune-control-plane +go build -o ../bin/schedune-cp ./cmd/schedune +cd .. + +# Clean old state +echo "[*] Cleaning local state..." +rm -f ./var/schedune.db + +# Start the server in background +echo "[*] Starting Control Plane on :9090..." +./bin/schedune-cp server > /dev/null 2>&1 & +SERVER_PID=$! + +# Trap to ensure server is killed and temp files cleaned on exit +trap "echo 'Shutting down control plane...'; kill $SERVER_PID 2>/dev/null || true; rm -f /tmp/fixture_arm.json /tmp/fixture_x86.json /tmp/freshen.py /tmp/validate.py /tmp/demo-launch.json" EXIT + +# Wait for healthz +echo "[*] Waiting for control plane readiness..." +RETRIES=10 +while [ $RETRIES -gt 0 ]; do + if curl -s -f http://127.0.0.1:9090/api/v1alpha1/healthz > /dev/null; then + echo "[*] Control plane is up!" + break + fi + sleep 1 + RETRIES=$((RETRIES-1)) +done + +if [ $RETRIES -eq 0 ]; then + echo "Error: Control plane failed to start." + exit 1 +fi + +format_json() { + if command -v python3 >/dev/null 2>&1; then + python3 -m json.tool || true + else + cat + echo "" + fi +} + +echo "[*] Freshening fixtures to avoid stale telemetry rejections..." +if command -v python3 >/dev/null 2>&1; then +cat << 'EOF' > /tmp/freshen.py +import json, time, sys +now = int(time.time()) +stale_after = now + 3600 + +with open(sys.argv[1], 'r') as f: + data = json.load(f) + +data['timestamp_sec'] = now +if 'capabilities' in data: + for cap in data['capabilities']: + cap['observed_at_sec'] = now + cap['stale_after_sec'] = stale_after + +with open(sys.argv[2], 'w') as f: + json.dump(data, f) +EOF + python3 /tmp/freshen.py testdata/fixtures/cloudhypervisor_ready_arm.json /tmp/fixture_arm.json + python3 /tmp/freshen.py testdata/fixtures/missing_kvm_x86.json /tmp/fixture_x86.json +else + echo "[!] python3 not found. Falling back to raw fixtures (telemetry may be stale)." + cp testdata/fixtures/cloudhypervisor_ready_arm.json /tmp/fixture_arm.json + cp testdata/fixtures/missing_kvm_x86.json /tmp/fixture_x86.json +fi + +# Ingest fixtures +echo "[*] Ingesting fixtures..." +curl -s -X POST -H "Content-Type: application/json" -d @/tmp/fixture_arm.json http://127.0.0.1:9090/api/v1alpha1/intake/envelope | format_json +echo "" +curl -s -X POST -H "Content-Type: application/json" -d @/tmp/fixture_x86.json http://127.0.0.1:9090/api/v1alpha1/intake/envelope | format_json +echo "" + +echo "" +echo "[*] Listing current cluster nodes:" +curl -s http://127.0.0.1:9090/api/v1alpha1/nodes | format_json +echo "" + +echo "" +echo "[*] Running scheduling explainability for ARM VM intent (Positive Path)..." +curl -s -X POST -H "Content-Type: application/json" -d @examples/workload-intents/vm-arm64.json http://127.0.0.1:9090/api/v1alpha1/schedule/explain | format_json +echo "" + +echo "" +echo "[*] Running scheduling explainability for x86 VM intent (Expected Rejection Path)..." +curl -s -X POST -H "Content-Type: application/json" -d @examples/workload-intents/vm-x86.json http://127.0.0.1:9090/api/v1alpha1/schedule/explain | format_json +echo "" + +echo "" +echo "[*] Evaluating launch validation against ARM node (cloud_hypervisor)..." +if command -v python3 >/dev/null 2>&1; then + NODE_ID=$(python3 -c "import json; print(json.load(open('/tmp/fixture_arm.json'))['node_id'])") +else + NODE_ID=$(grep -o '"node_id"[[:space:]]*:[[:space:]]*"[^"]*"' /tmp/fixture_arm.json | head -n 1 | cut -d'"' -f4) +fi + +# Run validation using node ID +if command -v python3 >/dev/null 2>&1; then +cat << 'EOF' > /tmp/validate.py +import json, sys +with open(sys.argv[1], 'r') as f: + data = json.load(f) +data['node_id'] = sys.argv[2] +data['architecture'] = 'aarch64' +with open(sys.argv[3], 'w') as f: + json.dump(data, f) +EOF + python3 /tmp/validate.py examples/launch-specs/cloudhypervisor-validate.json "$NODE_ID" /tmp/demo-launch.json +else + cat examples/launch-specs/cloudhypervisor-validate.json | sed "s/\"node_id\": \".*\"/\"node_id\": \"$NODE_ID\"/" | sed "s/\"architecture\": \"x86_64\"/\"architecture\": \"aarch64\"/" > /tmp/demo-launch.json +fi + +curl -s -X POST -H "Content-Type: application/json" -d @/tmp/demo-launch.json http://127.0.0.1:9090/api/v1alpha1/launch/validate | format_json +echo "" + +echo "" +echo "==========================================================" +echo "Demo complete! The Schedune control plane is still running." +echo "You can interact with it on http://127.0.0.1:9090" +echo "" +echo "Try running:" +echo " make example-nodes" +echo " make example-node-explain NODE_ID=$NODE_ID" +echo "" +echo "Press Ctrl+C to stop the server." +echo "==========================================================" + +if [ "$DEMO_ONCE" -eq 1 ]; then + echo "Exiting one-shot demo mode." + exit 0 +fi + +# Keep script running to keep server alive +wait $SERVER_PID