Skip to content

Commit ad4ab0a

Browse files
feat: prepare runtime control sockets (#31)
1 parent 91f3b19 commit ad4ab0a

6 files changed

Lines changed: 206 additions & 27 deletions

File tree

schedune-control-plane/internal/runtime/cloudhypervisor.go

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"os"
66
"os/exec"
7+
"path/filepath"
78

89
"github.com/TechnologyTailors/Schedune/schedune-control-plane/pkg/schema/launch"
910
)
@@ -24,20 +25,28 @@ func (k *CloudHypervisorExecutor) Prepare(spec launch.LaunchSpec) (launch.Prepar
2425
return launch.PreparedLaunch{}, fmt.Errorf("artifact missing at host path: %s", artifactPath)
2526
}
2627

28+
controlSocket, err := GetControlSocketPath(spec.WorkloadID, "cloudhypervisor")
29+
if err != nil {
30+
return launch.PreparedLaunch{}, fmt.Errorf("failed to resolve control socket: %w", err)
31+
}
32+
2733
args := []string{
2834
"--memory", fmt.Sprintf("size=%dM", spec.MemoryMB),
2935
"--cpus", fmt.Sprintf("boot=%d", spec.Vcpu),
3036
"--disk", fmt.Sprintf("path=%s", artifactPath),
37+
"--api-socket", controlSocket,
3138
}
3239

3340
return launch.PreparedLaunch{
34-
RuntimeBackend: "cloud_hypervisor",
35-
MemoryMB: spec.MemoryMB,
36-
Vcpu: spec.Vcpu,
41+
RuntimeBackend: "cloud_hypervisor",
42+
MemoryMB: spec.MemoryMB,
43+
Vcpu: spec.Vcpu,
44+
StartupGraceSec: 3,
3745
CloudHypervisor: &launch.PreparedCloudHypervisorLaunch{
38-
BinaryPath: binPath,
39-
ArtifactPath: artifactPath,
40-
CommandArgs: args,
46+
BinaryPath: binPath,
47+
ArtifactPath: artifactPath,
48+
CommandArgs: args,
49+
ControlSocketPath: controlSocket,
4150
},
4251
}, nil
4352
}
@@ -46,6 +55,13 @@ func (k *CloudHypervisorExecutor) Execute(prepared launch.PreparedLaunch) (int,
4655
if prepared.CloudHypervisor == nil {
4756
return 0, fmt.Errorf("missing cloud_hypervisor prepared state")
4857
}
58+
59+
if prepared.CloudHypervisor.ControlSocketPath != "" {
60+
if err := os.MkdirAll(filepath.Dir(prepared.CloudHypervisor.ControlSocketPath), 0755); err != nil {
61+
return 0, fmt.Errorf("failed to create runtime directory: %w", err)
62+
}
63+
}
64+
4965
cmd := exec.Command(prepared.CloudHypervisor.BinaryPath, prepared.CloudHypervisor.CommandArgs...)
5066
if err := cmd.Start(); err != nil {
5167
return 0, fmt.Errorf("executable failed to start: %w", err)

schedune-control-plane/internal/runtime/firecracker_validate.go

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package runtime
22

33
import (
44
"fmt"
5+
"path/filepath"
56

67
"github.com/TechnologyTailors/Schedune/schedune-control-plane/pkg/schema/launch"
78
)
@@ -18,22 +19,31 @@ func (k *FirecrackerExecutor) Prepare(spec launch.LaunchSpec) (launch.PreparedLa
1819
return launch.PreparedLaunch{}, fmt.Errorf("missing kernel or rootfs path for firecracker artifact model")
1920
}
2021

22+
controlSocket, err := GetControlSocketPath(spec.WorkloadID, "firecracker")
23+
if err != nil {
24+
return launch.PreparedLaunch{}, fmt.Errorf("failed to resolve control socket: %w", err)
25+
}
26+
27+
runtimeDir := filepath.Dir(controlSocket)
28+
2129
args := []string{
22-
"--api-sock", "/tmp/firecracker.socket",
30+
"--api-sock", controlSocket,
2331
// In a real execution, we'd write a config JSON and pass it, or call the API.
2432
// For V0 dry-run, we just mock the arguments.
25-
"--config-file", "/tmp/fc-config.json",
33+
"--config-file", filepath.Join(runtimeDir, "fc-config.json"),
2634
}
2735

2836
return launch.PreparedLaunch{
29-
RuntimeBackend: "firecracker",
30-
MemoryMB: spec.MemoryMB,
31-
Vcpu: spec.Vcpu,
37+
RuntimeBackend: "firecracker",
38+
MemoryMB: spec.MemoryMB,
39+
Vcpu: spec.Vcpu,
40+
StartupGraceSec: 2,
3241
Firecracker: &launch.PreparedFirecrackerLaunch{
33-
BinaryPath: binPath,
34-
KernelImagePath: kernel,
35-
RootfsPath: rootfs,
36-
CommandArgs: args,
42+
BinaryPath: binPath,
43+
KernelImagePath: kernel,
44+
RootfsPath: rootfs,
45+
CommandArgs: args,
46+
ControlSocketPath: controlSocket,
3747
},
3848
}, nil
3949
}

schedune-control-plane/internal/runtime/kvm.go

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"os"
66
"os/exec"
7+
"path/filepath"
78

89
"github.com/TechnologyTailors/Schedune/schedune-control-plane/pkg/schema/launch"
910
)
@@ -32,21 +33,29 @@ func (k *KvmExecutor) Prepare(spec launch.LaunchSpec) (launch.PreparedLaunch, er
3233
return launch.PreparedLaunch{}, fmt.Errorf("artifact missing at host path: %s", artifactPath)
3334
}
3435

36+
controlSocket, err := GetControlSocketPath(spec.WorkloadID, "qemu")
37+
if err != nil {
38+
return launch.PreparedLaunch{}, fmt.Errorf("failed to resolve control socket: %w", err)
39+
}
40+
3541
args := []string{
3642
"-m", fmt.Sprintf("%d", spec.MemoryMB),
3743
"-smp", fmt.Sprintf("%d", spec.Vcpu),
3844
"-drive", fmt.Sprintf("file=%s,format=%s", artifactPath, format),
3945
"-nographic",
46+
"-qmp", fmt.Sprintf("unix:%s,server,nowait", controlSocket),
4047
}
4148

4249
return launch.PreparedLaunch{
43-
RuntimeBackend: "kvm_qemu",
44-
MemoryMB: spec.MemoryMB,
45-
Vcpu: spec.Vcpu,
50+
RuntimeBackend: "kvm_qemu",
51+
MemoryMB: spec.MemoryMB,
52+
Vcpu: spec.Vcpu,
53+
StartupGraceSec: 5,
4654
KvmQemu: &launch.PreparedQemuLaunch{
47-
BinaryPath: binPath,
48-
ArtifactPath: artifactPath,
49-
CommandArgs: args,
55+
BinaryPath: binPath,
56+
ArtifactPath: artifactPath,
57+
CommandArgs: args,
58+
ControlSocketPath: controlSocket,
5059
},
5160
}, nil
5261
}
@@ -55,6 +64,14 @@ func (k *KvmExecutor) Execute(prepared launch.PreparedLaunch) (int, error) {
5564
if prepared.KvmQemu == nil {
5665
return 0, fmt.Errorf("missing kvm_qemu prepared state")
5766
}
67+
68+
// Ensure the runtime directory exists before starting the binary
69+
if prepared.KvmQemu.ControlSocketPath != "" {
70+
if err := os.MkdirAll(filepath.Dir(prepared.KvmQemu.ControlSocketPath), 0755); err != nil {
71+
return 0, fmt.Errorf("failed to create runtime directory: %w", err)
72+
}
73+
}
74+
5875
cmd := exec.Command(prepared.KvmQemu.BinaryPath, prepared.KvmQemu.CommandArgs...)
5976
if err := cmd.Start(); err != nil {
6077
return 0, fmt.Errorf("executable failed to start: %w", err)

schedune-control-plane/internal/runtime/kvm_test.go

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package runtime
22

33
import (
4+
"fmt"
45
"github.com/TechnologyTailors/Schedune/schedune-control-plane/pkg/schema/launch"
56
"os"
67
"strings"
@@ -11,6 +12,7 @@ func TestKvmExecutor_PrepareMissingImage(t *testing.T) {
1112
exec := &KvmExecutor{}
1213

1314
spec := launch.LaunchSpec{
15+
WorkloadID: "test-missing-id",
1416
Architecture: "aarch64",
1517
Storage: []launch.StorageAttachmentSpec{
1618
{HostPath: "/tmp/non_existent_image_12345.qcow2", Format: "qcow2"},
@@ -35,6 +37,7 @@ func TestKvmExecutor_PrepareValidImageLegacy(t *testing.T) {
3537
defer os.Remove(f.Name())
3638

3739
spec := launch.LaunchSpec{
40+
WorkloadID: "test-legacy-id",
3841
Architecture: "aarch64",
3942
ImageReference: f.Name(),
4043
Vcpu: 2,
@@ -43,7 +46,7 @@ func TestKvmExecutor_PrepareValidImageLegacy(t *testing.T) {
4346

4447
prep, err := exec.Prepare(spec)
4548
if err != nil {
46-
t.Errorf("expected Prepare to succeed, got %v", err)
49+
t.Fatalf("expected Prepare to succeed, got %v", err)
4750
}
4851

4952
if prep.KvmQemu == nil {
@@ -54,15 +57,26 @@ func TestKvmExecutor_PrepareValidImageLegacy(t *testing.T) {
5457
t.Errorf("expected qemu-system-aarch64, got %s", prep.KvmQemu.BinaryPath)
5558
}
5659

60+
if prep.KvmQemu.ControlSocketPath == "" || !strings.Contains(prep.KvmQemu.ControlSocketPath, "test-legacy-id/qemu.sock") {
61+
t.Errorf("expected qemu control socket path, got %v", prep.KvmQemu.ControlSocketPath)
62+
}
63+
5764
foundDrive := false
58-
for _, arg := range prep.KvmQemu.CommandArgs {
65+
foundQmp := false
66+
for i, arg := range prep.KvmQemu.CommandArgs {
5967
if strings.Contains(arg, "format=qcow2") {
6068
foundDrive = true
6169
}
70+
if arg == "-qmp" && i+1 < len(prep.KvmQemu.CommandArgs) && prep.KvmQemu.CommandArgs[i+1] == fmt.Sprintf("unix:%s,server,nowait", prep.KvmQemu.ControlSocketPath) {
71+
foundQmp = true
72+
}
6273
}
6374
if !foundDrive {
6475
t.Errorf("expected format=qcow2 in args, got %v", prep.KvmQemu.CommandArgs)
6576
}
77+
if !foundQmp {
78+
t.Errorf("expected exact -qmp socket in args, got %v", prep.KvmQemu.CommandArgs)
79+
}
6680
}
6781

6882
func TestKvmExecutor_PrepareValidImageTyped(t *testing.T) {
@@ -75,6 +89,7 @@ func TestKvmExecutor_PrepareValidImageTyped(t *testing.T) {
7589
defer os.Remove(f.Name())
7690

7791
spec := launch.LaunchSpec{
92+
WorkloadID: "test-typed-id",
7893
Architecture: "x86_64",
7994
Storage: []launch.StorageAttachmentSpec{
8095
{HostPath: f.Name(), Format: "raw"},
@@ -85,7 +100,7 @@ func TestKvmExecutor_PrepareValidImageTyped(t *testing.T) {
85100

86101
prep, err := exec.Prepare(spec)
87102
if err != nil {
88-
t.Errorf("expected Prepare to succeed, got %v", err)
103+
t.Fatalf("expected Prepare to succeed, got %v", err)
89104
}
90105

91106
if prep.KvmQemu == nil {
@@ -113,6 +128,7 @@ func TestCloudHypervisorExecutor_PrepareValidImageTyped(t *testing.T) {
113128
defer os.Remove(f.Name())
114129

115130
spec := launch.LaunchSpec{
131+
WorkloadID: "test-ch-id",
116132
Architecture: "x86_64",
117133
Storage: []launch.StorageAttachmentSpec{
118134
{HostPath: f.Name(), Format: "raw"},
@@ -123,28 +139,40 @@ func TestCloudHypervisorExecutor_PrepareValidImageTyped(t *testing.T) {
123139

124140
prep, err := exec.Prepare(spec)
125141
if err != nil {
126-
t.Errorf("expected Prepare to succeed, got %v", err)
142+
t.Fatalf("expected Prepare to succeed, got %v", err)
127143
}
128144

129145
if prep.CloudHypervisor == nil {
130146
t.Fatalf("expected CloudHypervisor prepared state, got nil")
131147
}
132148

149+
if prep.CloudHypervisor.ControlSocketPath == "" || !strings.Contains(prep.CloudHypervisor.ControlSocketPath, "test-ch-id/cloudhypervisor.sock") {
150+
t.Errorf("expected cloudhypervisor control socket path, got %v", prep.CloudHypervisor.ControlSocketPath)
151+
}
152+
133153
foundDrive := false
134-
for _, arg := range prep.CloudHypervisor.CommandArgs {
154+
foundApi := false
155+
for i, arg := range prep.CloudHypervisor.CommandArgs {
135156
if strings.Contains(arg, "path="+f.Name()) {
136157
foundDrive = true
137158
}
159+
if arg == "--api-socket" && i+1 < len(prep.CloudHypervisor.CommandArgs) && prep.CloudHypervisor.CommandArgs[i+1] == prep.CloudHypervisor.ControlSocketPath {
160+
foundApi = true
161+
}
138162
}
139163
if !foundDrive {
140164
t.Errorf("expected path in args, got %v", prep.CloudHypervisor.CommandArgs)
141165
}
166+
if !foundApi {
167+
t.Errorf("expected api-socket in args matching control socket, got %v", prep.CloudHypervisor.CommandArgs)
168+
}
142169
}
143170

144171
func TestFirecrackerExecutor_PrepareValidImageTyped(t *testing.T) {
145172
exec := &FirecrackerExecutor{}
146173

147174
spec := launch.LaunchSpec{
175+
WorkloadID: "test-fc-id",
148176
Architecture: "x86_64",
149177
Storage: []launch.StorageAttachmentSpec{
150178
{HostPath: "/tmp/rootfs.ext4", Format: "ext4", MountPoint: "/"},
@@ -156,7 +184,7 @@ func TestFirecrackerExecutor_PrepareValidImageTyped(t *testing.T) {
156184

157185
prep, err := exec.Prepare(spec)
158186
if err != nil {
159-
t.Errorf("expected Prepare to succeed, got %v", err)
187+
t.Fatalf("expected Prepare to succeed, got %v", err)
160188
}
161189

162190
if prep.Firecracker == nil {
@@ -169,6 +197,28 @@ func TestFirecrackerExecutor_PrepareValidImageTyped(t *testing.T) {
169197
if prep.Firecracker.KernelImagePath != "/tmp/vmlinux" {
170198
t.Errorf("expected kernel image path to be set from typed storage")
171199
}
200+
201+
if prep.Firecracker.ControlSocketPath == "" || !strings.Contains(prep.Firecracker.ControlSocketPath, "test-fc-id/firecracker.sock") {
202+
t.Errorf("expected firecracker control socket path, got %v", prep.Firecracker.ControlSocketPath)
203+
}
204+
205+
foundApi := false
206+
foundConfig := false
207+
for i, arg := range prep.Firecracker.CommandArgs {
208+
if arg == "--api-sock" && i+1 < len(prep.Firecracker.CommandArgs) && prep.Firecracker.CommandArgs[i+1] == prep.Firecracker.ControlSocketPath {
209+
foundApi = true
210+
}
211+
if arg == "--config-file" && i+1 < len(prep.Firecracker.CommandArgs) && strings.Contains(prep.Firecracker.CommandArgs[i+1], "test-fc-id/fc-config.json") {
212+
foundConfig = true
213+
}
214+
}
215+
216+
if !foundApi {
217+
t.Errorf("expected --api-sock argument with control socket path, got %v", prep.Firecracker.CommandArgs)
218+
}
219+
if !foundConfig {
220+
t.Errorf("expected --config-file argument with config path, got %v", prep.Firecracker.CommandArgs)
221+
}
172222
}
173223

174224
func TestKvmExecutor_ExecuteSpawnFails(t *testing.T) {
@@ -179,6 +229,7 @@ func TestKvmExecutor_ExecuteSpawnFails(t *testing.T) {
179229
KvmQemu: &launch.PreparedQemuLaunch{
180230
BinaryPath: "qemu-system-non-existent-binary-12345",
181231
CommandArgs: []string{"-m", "1024"},
232+
// Omit ControlSocketPath to intentionally bypass MkdirAll during this test
182233
},
183234
}
184235

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package runtime
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
"regexp"
7+
)
8+
9+
var validIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
10+
11+
// SanitizeWorkloadID returns the ID if it strictly matches safe characters, preventing path traversal.
12+
func SanitizeWorkloadID(id string) (string, error) {
13+
if !validIDPattern.MatchString(id) {
14+
return "", fmt.Errorf("invalid characters in workload ID: %s", id)
15+
}
16+
return id, nil
17+
}
18+
19+
// GetRuntimeDir returns the deterministic host directory for a specific workload's runtime artifacts.
20+
// The resulting path is isolated under var/run/schedune to ensure a predictable and secure workspace.
21+
func GetRuntimeDir(workloadID string) (string, error) {
22+
safeID, err := SanitizeWorkloadID(workloadID)
23+
if err != nil {
24+
return "", err
25+
}
26+
return filepath.Join("var", "run", "schedune", safeID), nil
27+
}
28+
29+
// GetControlSocketPath returns the deterministic path for the runtime control socket.
30+
func GetControlSocketPath(workloadID, backend string) (string, error) {
31+
dir, err := GetRuntimeDir(workloadID)
32+
if err != nil {
33+
return "", err
34+
}
35+
if !validIDPattern.MatchString(backend) {
36+
return "", fmt.Errorf("invalid characters in backend token: %s", backend)
37+
}
38+
return filepath.Join(dir, fmt.Sprintf("%s.sock", backend)), nil
39+
}

0 commit comments

Comments
 (0)