Skip to content

Commit 0e76f25

Browse files
Add live lab runtime demo (#34)
1 parent f5f3828 commit 0e76f25

17 files changed

Lines changed: 530 additions & 6 deletions

File tree

Makefile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ demo-fixture: build-control-plane dev-db-reset ## Run the fixture-backed evaluat
6969
demo-fixture-once: build-control-plane dev-db-reset ## Run the fixture-backed evaluator demo and exit cleanly
7070
@bash scripts/demo-fixture.sh --once
7171

72+
demo-live-lab: build dev-db-reset ## Run the live lab KVM demo interactively
73+
@bash scripts/demo-live-lab.sh
74+
75+
demo-live-lab-once: build dev-db-reset ## Run the live lab KVM demo and exit cleanly
76+
@bash scripts/demo-live-lab.sh --once
77+
7278
example-nodes: ## List normalized node summaries
7379
@curl -s http://127.0.0.1:9090/api/v1alpha1/nodes
7480

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,16 @@ make example-orphans # Check for orphaned processes
101101
make dev-down # Stop the control plane
102102
```
103103

104+
### 4. Live Lab Demo
105+
106+
If you are on an x86_64 Linux host or nested-KVM x86_64 environment with KVM and QEMU, you can run the live lab demo which executes a KVM workload natively:
107+
108+
```bash
109+
make demo-live-lab
110+
```
111+
112+
For more information, see the [Live Lab Demo](docs/live-lab.md) documentation.
113+
104114
## Repository Layout
105115

106116
- `schedune-control-plane/`: The Go-based control plane, API, and orchestration logic.

docs/index.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,8 @@ Schedune **is not yet** capable of full workload compatibility discovery/import,
3737
- **Lifecycle management:** Rigorous state machine with append-only traces, robust restart recovery (rehydration), and real `/proc`-backed orphan sweeping.
3838
- **Supported Runtimes:** KVM/QEMU (Execution), Cloud Hypervisor (Execution), Firecracker (Validation/Dry-run only).
3939

40+
## Live Lab Demo
41+
42+
To try out a live execution of a KVM/QEMU workload locally, see the [Live Lab Demo](live-lab.md) documentation.
43+
4044
*Note: Expect rapid changes as the product shape stabilizes.*

docs/live-lab.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Live Lab Demo
2+
3+
The `demo-live-lab` script provides an interactive demonstration of Schedune's `VirtualMachine` runtime lifecycle utilizing KVM and QEMU.
4+
5+
It runs locally on an x86_64 Linux host or nested-KVM x86_64 environment that possesses KVM extensions and the `qemu-system` binaries.
6+
7+
## What it does
8+
9+
The demo proves host node ingestion, runtime lifecycle, and backend readiness by completing the following sequence:
10+
11+
1. **Preflight Checks:** Ensures you have KVM extensions and QEMU.
12+
2. **Environment Reset:** Drops the local state.
13+
3. **Agent Inspect & Control Plane Start:** Scrapes host capabilities locally, generating a `node_id`, and pushes them via Schedune Intake.
14+
4. **Artifact Creation:** Generates a 10MB empty `qcow2` image.
15+
* *Note: The demo intentionally uses an empty `qcow2` because its focus is proving the underlying KVM execution and capability engine, not booting a specific Guest OS or Application.*
16+
5. **Explain & Predict:** Analyzes why an architecture-specific VM requiring KVM maps strictly to the local Node's Holding Pool.
17+
6. **Validate & Dry-Run:** Confirms the Launch Specification and simulates execution.
18+
7. **Execute:** Directly spawns a `qemu-system-*` process on the node via Schedune.
19+
8. **Lifecycle & Readiness:** Polls until the control socket reports the virtual machine is actively listening.
20+
9. **Trace & Events:** Shows the recorded telemetry trace and state machine events for the Launch.
21+
10. **Termination:** Issues an API call to tear down the underlying process.
22+
11. **Orphans:** Confirms no lingering processes exist in the Recovery engine.
23+
24+
## Usage
25+
26+
```bash
27+
# Build Schedune binaries
28+
make build
29+
30+
# Run the Live Lab interactively
31+
make demo-live-lab
32+
33+
# Run the Live Lab and exit automatically
34+
make demo-live-lab-once
35+
```
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"schema_version": "v1alpha1",
3+
"launch_mode": "Execute",
4+
"workload_id": "example-qemu-vm",
5+
"tenant_id": "tenant-example",
6+
"node_id": "node-123",
7+
"runtime_class": "VirtualMachine",
8+
"runtime_backend_preference": "kvm_qemu",
9+
"architecture": "x86_64",
10+
"memory_mb": 128,
11+
"vcpu": 1,
12+
"storage": [
13+
{
14+
"host_path": "/var/lib/schedune/images/example.qcow2",
15+
"format": "qcow2"
16+
}
17+
]
18+
}

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ nav:
1616
- State Machine: state-machine.md
1717
- Recovery: recovery.md
1818
- Troubleshooting: troubleshooting.md
19+
- Live Lab Demo: live-lab.md
1920
- GitHub: https://github.com/TechnologyTailors/Schedune

schedune-control-plane/internal/api/launch_handlers.go

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import (
66
"net/http"
77

88
"github.com/TechnologyTailors/Schedune/schedune-control-plane/internal/domain"
9+
"github.com/TechnologyTailors/Schedune/schedune-control-plane/internal/domain/lifecycle"
910
"github.com/TechnologyTailors/Schedune/schedune-control-plane/internal/runtime"
11+
"github.com/TechnologyTailors/Schedune/schedune-control-plane/internal/runtime/inspect"
1012
"github.com/TechnologyTailors/Schedune/schedune-control-plane/internal/store"
1113
"github.com/TechnologyTailors/Schedune/schedune-control-plane/internal/store/sqlite"
1214
"github.com/TechnologyTailors/Schedune/schedune-control-plane/pkg/schema"
@@ -27,11 +29,29 @@ func (r *StaticExecutorResolver) Resolve(backend string) (runtime.Executor, erro
2729
return exec, nil
2830
}
2931

32+
type InspectorResolver interface {
33+
Resolve(backend string) inspect.Inspector
34+
}
35+
36+
type StaticInspectorResolver struct{}
37+
38+
func (r *StaticInspectorResolver) Resolve(backend string) inspect.Inspector {
39+
switch backend {
40+
case "kvm_qemu":
41+
return &inspect.QemuInspector{}
42+
case "cloud_hypervisor":
43+
return &inspect.CloudHypervisorInspector{}
44+
default:
45+
return &inspect.ProcessInspector{}
46+
}
47+
}
48+
3049
type LaunchHandler struct {
31-
nodeStore *store.InMemoryStore
32-
execStore *sqlite.SQLiteStore
33-
resolver *StaticExecutorResolver
34-
orch *domain.LaunchOrchestrator
50+
nodeStore *store.InMemoryStore
51+
execStore *sqlite.SQLiteStore
52+
resolver *StaticExecutorResolver
53+
inspectorResolver InspectorResolver
54+
orch *domain.LaunchOrchestrator
3555
}
3656

3757
func NewLaunchHandler(nodeStore *store.InMemoryStore, execStore *sqlite.SQLiteStore) *LaunchHandler {
@@ -43,7 +63,7 @@ func NewLaunchHandler(nodeStore *store.InMemoryStore, execStore *sqlite.SQLiteSt
4363
},
4464
}
4565
orch := domain.NewLaunchOrchestrator(nodeStore, execStore, resolver)
46-
return &LaunchHandler{nodeStore: nodeStore, execStore: execStore, resolver: resolver, orch: orch}
66+
return &LaunchHandler{nodeStore: nodeStore, execStore: execStore, resolver: resolver, inspectorResolver: &StaticInspectorResolver{}, orch: orch}
4767
}
4868

4969
// ValidateLaunch assesses whether a chosen node is physically capable of executing the requested spec.
@@ -135,6 +155,26 @@ func (h *LaunchHandler) ExecuteLaunch(c *gin.Context) {
135155
c.JSON(http.StatusAccepted, record)
136156
}
137157

158+
func (h *LaunchHandler) reconcileExecution(rec *launch.LaunchExecutionRecord) error {
159+
if rec.State == launch.StateExited || rec.State == launch.StateFailed || rec.State == launch.StateTerminated {
160+
return nil
161+
}
162+
163+
backend := ""
164+
if rec.PreparedState != nil {
165+
backend = rec.PreparedState.RuntimeBackend
166+
}
167+
inspector := h.inspectorResolver.Resolve(backend)
168+
169+
err := lifecycle.Reconcile(rec, inspector)
170+
if err != nil {
171+
log.Error().Err(err).Str("execution_id", rec.ExecutionID).Msg("Failed to reconcile execution")
172+
return err
173+
}
174+
175+
return h.execStore.SaveExecution(context.Background(), *rec)
176+
}
177+
138178
// InspectLaunch returns the live status of the execution trace
139179
func (h *LaunchHandler) InspectLaunch(c *gin.Context) {
140180
id := c.Param("id")
@@ -143,6 +183,11 @@ func (h *LaunchHandler) InspectLaunch(c *gin.Context) {
143183
c.JSON(http.StatusNotFound, gin.H{"error": "Execution not found"})
144184
return
145185
}
186+
187+
if err := h.reconcileExecution(&rec); err != nil {
188+
// Logged in reconcileExecution, continue to return current state
189+
}
190+
146191
c.JSON(http.StatusOK, rec)
147192
}
148193

@@ -166,6 +211,10 @@ func (h *LaunchHandler) InspectReadiness(c *gin.Context) {
166211
return
167212
}
168213

214+
if err := h.reconcileExecution(&rec); err != nil {
215+
// Logged in reconcileExecution, continue to return current state
216+
}
217+
169218
backend := ""
170219
if rec.PreparedState != nil {
171220
backend = rec.PreparedState.RuntimeBackend

schedune-control-plane/internal/api/launch_handlers_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package api
22

33
import (
44
"bytes"
5+
"context"
56
"encoding/json"
67
"errors"
78
"net/http"
@@ -11,6 +12,7 @@ import (
1112

1213
"github.com/TechnologyTailors/Schedune/schedune-control-plane/internal/domain"
1314
"github.com/TechnologyTailors/Schedune/schedune-control-plane/internal/runtime"
15+
"github.com/TechnologyTailors/Schedune/schedune-control-plane/internal/runtime/inspect"
1416
"github.com/TechnologyTailors/Schedune/schedune-control-plane/internal/store"
1517
"github.com/TechnologyTailors/Schedune/schedune-control-plane/internal/store/sqlite"
1618
"github.com/TechnologyTailors/Schedune/schedune-control-plane/pkg/schema"
@@ -248,3 +250,101 @@ func TestDryRunLaunch_PreparationFails(t *testing.T) {
248250
t.Errorf("expected preparation reason code, got %v", res.PreparationReasonCode)
249251
}
250252
}
253+
254+
type MockInspector struct {
255+
Obs inspect.RuntimeObservation
256+
Err error
257+
}
258+
259+
func (m *MockInspector) Inspect(executionID string, pid *int, prepared launch.PreparedLaunch) (inspect.RuntimeObservation, error) {
260+
return m.Obs, m.Err
261+
}
262+
263+
type MockInspectorResolver struct {
264+
Inspector inspect.Inspector
265+
}
266+
267+
func (m *MockInspectorResolver) Resolve(backend string) inspect.Inspector {
268+
return m.Inspector
269+
}
270+
271+
func setupInspectTestRouter(t *testing.T) (*gin.Engine, *store.InMemoryStore, *sqlite.SQLiteStore, *MockInspector) {
272+
gin.SetMode(gin.TestMode)
273+
router := gin.New()
274+
275+
nodeStore := store.NewInMemoryStore()
276+
execStore, _ := sqlite.NewSQLiteStore(":memory:")
277+
278+
resolver := &StaticExecutorResolver{}
279+
orch := domain.NewLaunchOrchestrator(nodeStore, execStore, resolver)
280+
281+
mockInspector := &MockInspector{}
282+
mockResolver := &MockInspectorResolver{Inspector: mockInspector}
283+
284+
handler := &LaunchHandler{
285+
nodeStore: nodeStore,
286+
execStore: execStore,
287+
resolver: resolver,
288+
inspectorResolver: mockResolver,
289+
orch: orch,
290+
}
291+
292+
router.GET("/launch/:id", handler.InspectLaunch)
293+
router.GET("/launch/:id/readiness", handler.InspectReadiness)
294+
295+
return router, nodeStore, execStore, mockInspector
296+
}
297+
298+
func TestInspectReadiness_ReconcilesToReady(t *testing.T) {
299+
router, _, execStore, mockInspector := setupInspectTestRouter(t)
300+
301+
// Insert a Starting execution
302+
pid := 1234
303+
now := time.Now().Unix()
304+
rec := launch.LaunchExecutionRecord{
305+
ExecutionID: "test-exec-1",
306+
NodeID: "test-node",
307+
State: launch.StateStarting,
308+
PID: &pid,
309+
StartedAtSec: &now,
310+
}
311+
execStore.SaveExecution(context.Background(), rec)
312+
313+
// Mock observation: Process is alive and ready
314+
mockInspector.Obs = inspect.RuntimeObservation{
315+
ProcessExists: true,
316+
BackendReadySignal: true,
317+
BackendSignalSource: "mock-backend",
318+
ObservedAtSec: now,
319+
}
320+
321+
// Request readiness
322+
req, _ := http.NewRequest("GET", "/launch/test-exec-1/readiness", nil)
323+
w := httptest.NewRecorder()
324+
router.ServeHTTP(w, req)
325+
326+
if w.Code != http.StatusOK {
327+
t.Fatalf("expected status 200, got %d", w.Code)
328+
}
329+
330+
var res launch.LaunchExecutionRecord
331+
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
332+
t.Fatalf("failed to unmarshal response: %v", err)
333+
}
334+
335+
if res.State != launch.StateRunning {
336+
t.Errorf("expected state to be Running, got %s", res.State)
337+
}
338+
if res.RuntimeReadiness != "Ready" {
339+
t.Errorf("expected readiness to be Ready, got %s", res.RuntimeReadiness)
340+
}
341+
342+
// Verify it was persisted
343+
persisted, _, err := execStore.GetExecution(context.Background(), "test-exec-1")
344+
if err != nil {
345+
t.Fatalf("failed to get execution: %v", err)
346+
}
347+
if persisted.State != launch.StateRunning {
348+
t.Errorf("expected persisted state to be Running, got %s", persisted.State)
349+
}
350+
}

schedune-control-plane/internal/api/orphan_handlers.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ func (h *OrphanHandler) ListOrphans(c *gin.Context) {
3131
return
3232
}
3333

34+
if orphans == nil {
35+
orphans = []domain.OrphanRecord{}
36+
}
37+
3438
c.JSON(http.StatusOK, gin.H{"orphans": orphans})
3539
}
3640

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/TechnologyTailors/Schedune/schedune-control-plane/internal/store/sqlite"
10+
"github.com/gin-gonic/gin"
11+
)
12+
13+
func TestOrphanHandler_ListOrphans_Empty(t *testing.T) {
14+
gin.SetMode(gin.TestMode)
15+
16+
// Use an in-memory sqlite db for tests
17+
dbStore, err := sqlite.NewSQLiteStore(":memory:")
18+
if err != nil {
19+
t.Fatalf("failed to create sqlite store: %v", err)
20+
}
21+
22+
handler := NewOrphanHandler(dbStore)
23+
24+
w := httptest.NewRecorder()
25+
c, _ := gin.CreateTestContext(w)
26+
// Add mock request to context so query params don't panic
27+
c.Request, _ = http.NewRequest(http.MethodGet, "/api/v1alpha1/recovery/orphans", nil)
28+
29+
handler.ListOrphans(c)
30+
31+
if w.Code != http.StatusOK {
32+
t.Errorf("Expected status 200, got %d", w.Code)
33+
}
34+
35+
var response map[string]interface{}
36+
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
37+
t.Fatalf("Failed to parse response JSON: %v", err)
38+
}
39+
40+
// Since there are no orphans in the fresh DB, it should return an empty slice, not nil
41+
orphansInter, ok := response["orphans"]
42+
if !ok {
43+
t.Fatalf("Expected 'orphans' key in response")
44+
}
45+
46+
orphansList, ok := orphansInter.([]interface{})
47+
if !ok {
48+
t.Fatalf("Expected 'orphans' to be a list, got %T", orphansInter)
49+
}
50+
51+
if len(orphansList) != 0 {
52+
t.Errorf("Expected empty list, got length %d", len(orphansList))
53+
}
54+
}

0 commit comments

Comments
 (0)