Skip to content

Commit 5b60b68

Browse files
feat: Add optional runtime binary version requirements to LaunchSpec (#26)
- Added typed RuntimeVersionRequirement to LaunchSpec (minimum/exact) - Validated observed versions against KVM, Cloud Hypervisor, and Firecracker capabilities - Added custom semantic version parser to evaluate requirements without extra dependencies - Registered standard ReasonCodes for runtime version blockers - Introduced detailed trace evidence and actionable remediation hints for runtime validation - Added comprehensive unit tests for version validation and behavior
1 parent e4e410f commit 5b60b68

9 files changed

Lines changed: 327 additions & 5 deletions

File tree

schedune-control-plane/internal/domain/runtime_select.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ func SelectBackend(spec launch.LaunchSpec, node NodeRecord) (string, []launch.Ba
9292
reject("firecracker", schema.ReasonErrLaunchMissingCapabilityFcBinary, &capName, capPtr)
9393
return "", evidence, rejected
9494
}
95+
if ok, reason := checkVersionRequirement(binCap.Version, spec.RuntimeVersion); !ok {
96+
reject("firecracker", reason, &capName, &binCap)
97+
return "", evidence, rejected
98+
}
9599

96100
capName = "firecracker_tun_ready"
97101
tunCap, tunExists := node.Capabilities[capName]
@@ -173,6 +177,10 @@ func SelectBackend(spec launch.LaunchSpec, node NodeRecord) (string, []launch.Ba
173177
reject("cloud_hypervisor", schema.ReasonErrLaunchMissingCapabilityChBinary, &capName, capPtr)
174178
continue
175179
}
180+
if ok, reason := checkVersionRequirement(binCap.Version, spec.RuntimeVersion); !ok {
181+
reject("cloud_hypervisor", reason, &capName, &binCap)
182+
continue
183+
}
176184

177185
if len(storage) == 0 {
178186
reject("cloud_hypervisor", schema.ReasonErrLaunchMissingArtifact, nil, nil)
@@ -203,6 +211,10 @@ func SelectBackend(spec launch.LaunchSpec, node NodeRecord) (string, []launch.Ba
203211
reject("kvm_qemu", schema.ReasonErrLaunchMissingCapabilityQemuBinary, &capName, capPtr)
204212
continue
205213
}
214+
if ok, reason := checkVersionRequirement(binCap.Version, spec.RuntimeVersion); !ok {
215+
reject("kvm_qemu", reason, &capName, &binCap)
216+
continue
217+
}
206218

207219
if len(storage) == 0 {
208220
reject("kvm_qemu", schema.ReasonErrLaunchMissingArtifact, nil, nil)

schedune-control-plane/internal/domain/runtime_select_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,58 @@ func TestSelectBackend_Fallback(t *testing.T) {
8383
t.Errorf("expected kvm_qemu fallback, got %s. rejections: %v", backend, rejected)
8484
}
8585
}
86+
87+
func TestSelectBackend_RuntimeVersion(t *testing.T) {
88+
env := readFixture(t, "healthy_arm_production.json")
89+
now := time.Now().Unix()
90+
env.TimestampSec = now
91+
for i, cap := range env.Capabilities {
92+
env.Capabilities[i].ObservedAtSec = now
93+
staleAfter := now + 300
94+
env.Capabilities[i].StaleAfterSec = &staleAfter
95+
if cap.Feature == "qemu_binary_present" {
96+
v := "QEMU emulator version 6.2.0 (Debian 1:6.2+dfsg-2ubuntu6.22)"
97+
env.Capabilities[i].Version = &v
98+
}
99+
}
100+
node := ProjectEnvelope(env)
101+
102+
baseSpec := launch.LaunchSpec{
103+
RuntimeClass: "VirtualMachine",
104+
ImageReference: "/tmp/image.qcow2",
105+
}
106+
107+
tests := []struct {
108+
name string
109+
req *launch.RuntimeVersionRequirement
110+
expectOk bool
111+
expectErr string
112+
}{
113+
{"NoRequirement", nil, true, ""},
114+
{"SatisfiedMinimum", &launch.RuntimeVersionRequirement{MinimumVersion: "6.1.0"}, true, ""},
115+
{"TooOld", &launch.RuntimeVersionRequirement{MinimumVersion: "6.3.0"}, false, schema.ReasonErrLaunchRuntimeVersionTooOld},
116+
{"SatisfiedExact", &launch.RuntimeVersionRequirement{ExactVersion: "6.2.0"}, true, ""},
117+
{"MismatchExact", &launch.RuntimeVersionRequirement{ExactVersion: "6.2.1"}, false, schema.ReasonErrLaunchRuntimeVersionMismatch},
118+
{"UnparseableRequirement", &launch.RuntimeVersionRequirement{ExactVersion: "invalid"}, false, schema.ReasonErrLaunchRuntimeVersionUnparseable},
119+
}
120+
121+
for _, tt := range tests {
122+
t.Run(tt.name, func(t *testing.T) {
123+
spec := baseSpec
124+
spec.RuntimeVersion = tt.req
125+
backend, _, rejected := SelectBackend(spec, node)
126+
if tt.expectOk {
127+
if backend != "kvm_qemu" {
128+
t.Errorf("expected kvm_qemu, got %s. rejections: %v", backend, rejected)
129+
}
130+
} else {
131+
if backend != "" {
132+
t.Errorf("expected rejection, got %s", backend)
133+
}
134+
if reason := rejected["kvm_qemu"]; len(reason) < len(tt.expectErr) || reason[:len(tt.expectErr)] != tt.expectErr {
135+
t.Errorf("expected prefix %s, got %s", tt.expectErr, reason)
136+
}
137+
}
138+
})
139+
}
140+
}

schedune-control-plane/internal/domain/validate_launch.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ func ValidateLaunch(spec launch.LaunchSpec, node NodeRecord) launch.LaunchValida
8282

8383
// 3. Layer 4: Setup context for preparation phase validation
8484
result.ValidationTrace = append(result.ValidationTrace, "Passed: Selected backend "+selectedBackend)
85+
if spec.RuntimeVersion != nil && (spec.RuntimeVersion.MinimumVersion != "" || spec.RuntimeVersion.ExactVersion != "") {
86+
result.ValidationTrace = append(result.ValidationTrace, "Passed: Runtime version requirement satisfied by "+selectedBackend)
87+
}
8588

8689
result.ExplainabilityText = "Node is fully capable of executing this launch spec."
8790

@@ -144,6 +147,18 @@ func generateRemediationHints(result launch.LaunchValidationResult) map[string]s
144147
if backend == schema.BackendFirecracker && strings.Contains(reason, schema.ReasonCapKvmMissing) {
145148
hints["firecracker_kvm"] = "Enable KVM in BIOS or load kvm kernel modules."
146149
}
150+
if strings.Contains(reason, schema.ReasonErrLaunchRuntimeVersionUnknown) {
151+
hints["runtime_version_unknown"] = "Wait for the agent to observe the runtime version, or ensure the binary is correctly installed."
152+
}
153+
if strings.Contains(reason, schema.ReasonErrLaunchRuntimeVersionUnparseable) {
154+
hints["runtime_version_unparseable"] = "Ensure the requested version is a valid semver-like format, or check host agent logs."
155+
}
156+
if strings.Contains(reason, schema.ReasonErrLaunchRuntimeVersionTooOld) {
157+
hints["runtime_version_too_old"] = "Upgrade the runtime binary on the host to meet the minimum version requirement."
158+
}
159+
if strings.Contains(reason, schema.ReasonErrLaunchRuntimeVersionMismatch) {
160+
hints["runtime_version_mismatch"] = "Install the exact runtime binary version requested on the host."
161+
}
147162
if strings.Contains(reason, schema.ReasonErrLaunchMissingArtifact) {
148163
hints["artifact_missing"] = "Ensure ImageReference is provided for the workload."
149164
}

schedune-control-plane/internal/domain/validate_launch_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,3 +677,57 @@ func TestValidateLaunch_ReasonCodeRegistryHygiene(t *testing.T) {
677677
}
678678
}
679679
}
680+
681+
func TestValidateLaunch_RuntimeVersionMismatch(t *testing.T) {
682+
env := readFixture(t, "healthy_arm_production.json")
683+
now := time.Now().Unix()
684+
env.TimestampSec = now
685+
for i, cap := range env.Capabilities {
686+
env.Capabilities[i].ObservedAtSec = now
687+
staleAfter := now + 300
688+
env.Capabilities[i].StaleAfterSec = &staleAfter
689+
if cap.Feature == "qemu_binary_present" {
690+
v := "QEMU emulator version 6.2.0 (Debian 1:6.2+dfsg-2ubuntu6.22)"
691+
env.Capabilities[i].Version = &v
692+
}
693+
}
694+
node := ProjectEnvelope(env)
695+
696+
spec := launch.LaunchSpec{
697+
SchemaVersion: "v1alpha1",
698+
WorkloadID: "wl-launch-version",
699+
TenantID: "tenant-1",
700+
NodeID: node.ID,
701+
RuntimeClass: "VirtualMachine",
702+
Architecture: "aarch64",
703+
Vcpu: 2,
704+
MemoryMB: 1024,
705+
LaunchMode: "DryRun",
706+
Storage: []launch.StorageAttachmentSpec{
707+
{HostPath: "/tmp/vol.qcow2", Format: "qcow2"},
708+
},
709+
RuntimeVersion: &launch.RuntimeVersionRequirement{
710+
ExactVersion: "9.9.9",
711+
},
712+
}
713+
714+
result := ValidateLaunch(spec, node)
715+
if result.IsValid {
716+
t.Errorf("expected validation to fail due to runtime version mismatch")
717+
}
718+
719+
hint, ok := result.RemediationHints["runtime_version_mismatch"]
720+
if !ok || !strings.Contains(hint, "Install the exact runtime binary") {
721+
t.Errorf("expected runtime version mismatch hint, got %v", result.RemediationHints)
722+
}
723+
724+
foundEvidence := false
725+
for _, ev := range result.BackendRejectionEvidence {
726+
if ev.ReasonCode == schema.ReasonErrLaunchRuntimeVersionMismatch {
727+
foundEvidence = true
728+
}
729+
}
730+
if !foundEvidence {
731+
t.Errorf("expected structured evidence for runtime version mismatch, got %+v", result.BackendRejectionEvidence)
732+
}
733+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package domain
2+
3+
import (
4+
"regexp"
5+
"strconv"
6+
7+
"github.com/TechnologyTailors/Schedune/schedune-control-plane/pkg/schema"
8+
"github.com/TechnologyTailors/Schedune/schedune-control-plane/pkg/schema/launch"
9+
)
10+
11+
var versionRegex = regexp.MustCompile(`(\d+)\.(\d+)(?:\.(\d+))?`)
12+
13+
type parsedVersion struct {
14+
major int
15+
minor int
16+
patch int
17+
}
18+
19+
func parseVersion(raw string) (*parsedVersion, bool) {
20+
matches := versionRegex.FindStringSubmatch(raw)
21+
if len(matches) < 3 {
22+
return nil, false
23+
}
24+
25+
major, err := strconv.Atoi(matches[1])
26+
if err != nil {
27+
return nil, false
28+
}
29+
minor, err := strconv.Atoi(matches[2])
30+
if err != nil {
31+
return nil, false
32+
}
33+
patch := 0
34+
if len(matches) > 3 && matches[3] != "" {
35+
patch, err = strconv.Atoi(matches[3])
36+
if err != nil {
37+
return nil, false
38+
}
39+
}
40+
41+
return &parsedVersion{major, minor, patch}, true
42+
}
43+
44+
func compareVersions(a, b *parsedVersion) int {
45+
if a.major != b.major {
46+
if a.major < b.major {
47+
return -1
48+
}
49+
return 1
50+
}
51+
if a.minor != b.minor {
52+
if a.minor < b.minor {
53+
return -1
54+
}
55+
return 1
56+
}
57+
if a.patch != b.patch {
58+
if a.patch < b.patch {
59+
return -1
60+
}
61+
return 1
62+
}
63+
return 0
64+
}
65+
66+
func checkVersionRequirement(observedRaw string, req *launch.RuntimeVersionRequirement) (bool, string) {
67+
if req == nil || (req.MinimumVersion == "" && req.ExactVersion == "") {
68+
return true, ""
69+
}
70+
71+
if observedRaw == "" {
72+
return false, schema.ReasonErrLaunchRuntimeVersionUnknown
73+
}
74+
75+
observed, ok := parseVersion(observedRaw)
76+
if !ok {
77+
return false, schema.ReasonErrLaunchRuntimeVersionUnparseable
78+
}
79+
80+
if req.ExactVersion != "" {
81+
exact, ok := parseVersion(req.ExactVersion)
82+
if !ok {
83+
return false, schema.ReasonErrLaunchRuntimeVersionUnparseable
84+
}
85+
if compareVersions(observed, exact) != 0 {
86+
return false, schema.ReasonErrLaunchRuntimeVersionMismatch
87+
}
88+
}
89+
90+
if req.MinimumVersion != "" {
91+
min, ok := parseVersion(req.MinimumVersion)
92+
if !ok {
93+
return false, schema.ReasonErrLaunchRuntimeVersionUnparseable
94+
}
95+
if compareVersions(observed, min) < 0 {
96+
return false, schema.ReasonErrLaunchRuntimeVersionTooOld
97+
}
98+
}
99+
100+
return true, ""
101+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package domain
2+
3+
import (
4+
"testing"
5+
6+
"github.com/TechnologyTailors/Schedune/schedune-control-plane/pkg/schema"
7+
"github.com/TechnologyTailors/Schedune/schedune-control-plane/pkg/schema/launch"
8+
)
9+
10+
func TestParseVersion(t *testing.T) {
11+
tests := []struct {
12+
raw string
13+
expect parsedVersion
14+
ok bool
15+
}{
16+
{"QEMU emulator version 6.2.0 (Debian 1:6.2+dfsg-2ubuntu6.22)", parsedVersion{6, 2, 0}, true},
17+
{"cloud-hypervisor v32.0.0", parsedVersion{32, 0, 0}, true},
18+
{"Firecracker v1.4.0", parsedVersion{1, 4, 0}, true},
19+
{"v2.0", parsedVersion{2, 0, 0}, true},
20+
{"2.1", parsedVersion{2, 1, 0}, true},
21+
{"invalid", parsedVersion{}, false},
22+
}
23+
24+
for _, tt := range tests {
25+
v, ok := parseVersion(tt.raw)
26+
if ok != tt.ok {
27+
t.Errorf("parseVersion(%q) expected ok=%v, got %v", tt.raw, tt.ok, ok)
28+
continue
29+
}
30+
if ok && (*v != tt.expect) {
31+
t.Errorf("parseVersion(%q) expected %v, got %v", tt.raw, tt.expect, *v)
32+
}
33+
}
34+
}
35+
36+
func TestCheckVersionRequirement(t *testing.T) {
37+
v1_4_0 := "Firecracker v1.4.0"
38+
v1_5_0 := "Firecracker v1.5.0"
39+
invalid := "invalid"
40+
41+
tests := []struct {
42+
name string
43+
observedRaw string
44+
req *launch.RuntimeVersionRequirement
45+
expectOk bool
46+
expectErr string
47+
}{
48+
{"No requirement", v1_4_0, nil, true, ""},
49+
{"Empty requirement", v1_4_0, &launch.RuntimeVersionRequirement{}, true, ""},
50+
{"Missing observed version", "", &launch.RuntimeVersionRequirement{MinimumVersion: "1.0.0"}, false, schema.ReasonErrLaunchRuntimeVersionUnknown},
51+
{"Unparseable observed version", invalid, &launch.RuntimeVersionRequirement{MinimumVersion: "1.0.0"}, false, schema.ReasonErrLaunchRuntimeVersionUnparseable},
52+
{"Unparseable minimum version", v1_4_0, &launch.RuntimeVersionRequirement{MinimumVersion: "invalid"}, false, schema.ReasonErrLaunchRuntimeVersionUnparseable},
53+
{"Unparseable exact version", v1_4_0, &launch.RuntimeVersionRequirement{ExactVersion: "invalid"}, false, schema.ReasonErrLaunchRuntimeVersionUnparseable},
54+
{"Minimum satisfied", v1_5_0, &launch.RuntimeVersionRequirement{MinimumVersion: "1.4.0"}, true, ""},
55+
{"Minimum not satisfied", v1_4_0, &launch.RuntimeVersionRequirement{MinimumVersion: "1.5.0"}, false, schema.ReasonErrLaunchRuntimeVersionTooOld},
56+
{"Exact satisfied", v1_4_0, &launch.RuntimeVersionRequirement{ExactVersion: "1.4.0"}, true, ""},
57+
{"Exact not satisfied", v1_5_0, &launch.RuntimeVersionRequirement{ExactVersion: "1.4.0"}, false, schema.ReasonErrLaunchRuntimeVersionMismatch},
58+
}
59+
60+
for _, tt := range tests {
61+
t.Run(tt.name, func(t *testing.T) {
62+
ok, err := checkVersionRequirement(tt.observedRaw, tt.req)
63+
if ok != tt.expectOk {
64+
t.Errorf("checkVersionRequirement expected ok=%v, got %v", tt.expectOk, ok)
65+
}
66+
if err != tt.expectErr {
67+
t.Errorf("checkVersionRequirement expected err=%v, got %v", tt.expectErr, err)
68+
}
69+
})
70+
}
71+
}

schedune-control-plane/pkg/schema/constants.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ const (
9898
ReasonErrLaunchMissingCapabilityQemuBinary = "ERR_LAUNCH_MISSING_CAPABILITY_QEMU_BINARY"
9999
ReasonErrLaunchMissingCapabilitySeccomp = "ERR_LAUNCH_MISSING_CAPABILITY_SECCOMP"
100100
ReasonErrLaunchMissingCapabilityNamespaces = "ERR_LAUNCH_MISSING_CAPABILITY_NAMESPACES"
101+
ReasonErrLaunchRuntimeVersionUnknown = "ERR_LAUNCH_RUNTIME_VERSION_UNKNOWN"
102+
ReasonErrLaunchRuntimeVersionUnparseable = "ERR_LAUNCH_RUNTIME_VERSION_UNPARSEABLE"
103+
ReasonErrLaunchRuntimeVersionTooOld = "ERR_LAUNCH_RUNTIME_VERSION_TOO_OLD"
104+
ReasonErrLaunchRuntimeVersionMismatch = "ERR_LAUNCH_RUNTIME_VERSION_MISMATCH"
101105

102106
ReasonWarnDeprecatedImageReference = "WARN_DEPRECATED_IMAGE_REFERENCE"
103107
ReasonWarnDeprecatedNetworkAttachments = "WARN_DEPRECATED_NETWORK_ATTACHMENTS"

schedune-control-plane/pkg/schema/launch/v1alpha1.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ type SecurityContextSpec struct {
2222
DropCapabilities []string `json:"drop_capabilities,omitempty"`
2323
}
2424

25+
type RuntimeVersionRequirement struct {
26+
MinimumVersion string `json:"minimum_version,omitempty"`
27+
ExactVersion string `json:"exact_version,omitempty"`
28+
}
29+
2530
// LaunchSpec defines the runtime configuration to validate or dry-run.
2631
type LaunchSpec struct {
2732
SchemaVersion string `json:"schema_version" binding:"required,eq=v1alpha1"`
@@ -42,11 +47,12 @@ type LaunchSpec struct {
4247
Networks []NetworkAttachmentSpec `json:"networks,omitempty"`
4348
Security *SecurityContextSpec `json:"security,omitempty"`
4449

45-
Vcpu int `json:"vcpu" binding:"required,gt=0"`
46-
MemoryMB int64 `json:"memory_mb" binding:"required,gt=0"`
47-
LaunchMode string `json:"launch_mode" binding:"required,oneof=Validate DryRun Execute"`
48-
RuntimeBackendPreference string `json:"runtime_backend_preference,omitempty"`
49-
AllowBackendFallback bool `json:"allow_backend_fallback,omitempty"`
50+
Vcpu int `json:"vcpu" binding:"required,gt=0"`
51+
MemoryMB int64 `json:"memory_mb" binding:"required,gt=0"`
52+
LaunchMode string `json:"launch_mode" binding:"required,oneof=Validate DryRun Execute"`
53+
RuntimeBackendPreference string `json:"runtime_backend_preference,omitempty"`
54+
AllowBackendFallback bool `json:"allow_backend_fallback,omitempty"`
55+
RuntimeVersion *RuntimeVersionRequirement `json:"runtime_version,omitempty"`
5056
}
5157

5258
// LaunchValidationResult explains exactly what host-level blockers exist.

0 commit comments

Comments
 (0)