Skip to content

Commit 5e522f2

Browse files
authored
QEMU support (#48)
* Generate QEMU implementation * Simplify to higher level apis * Add otel integration * arm64 configs * Add review comment * Package hint * Add vsock dialer abstraction * QMP connection pool * Fix cleanup of vm * Address review comments * Add version checking to hypervisor interface * Fix log naming * fix indent * Add startup warning
1 parent e4b8399 commit 5e522f2

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+2720
-635
lines changed

cmd/api/api/api_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ func newTestService(t *testing.T) *ApiService {
4040
limits := instances.ResourceLimits{
4141
MaxOverlaySize: 100 * 1024 * 1024 * 1024, // 100GB
4242
}
43-
instanceMgr := instances.NewManager(p, imageMgr, systemMgr, networkMgr, deviceMgr, volumeMgr, limits, nil, nil)
43+
instanceMgr := instances.NewManager(p, imageMgr, systemMgr, networkMgr, deviceMgr, volumeMgr, limits, "", nil, nil)
4444

4545
// Register cleanup for orphaned Cloud Hypervisor processes
4646
t.Cleanup(func() {

cmd/api/api/cp.go

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"github.com/gorilla/websocket"
1313
"github.com/onkernel/hypeman/lib/guest"
14+
"github.com/onkernel/hypeman/lib/hypervisor"
1415
"github.com/onkernel/hypeman/lib/instances"
1516
"github.com/onkernel/hypeman/lib/logger"
1617
mw "github.com/onkernel/hypeman/lib/middleware"
@@ -218,7 +219,13 @@ func (s *ApiService) CpHandler(w http.ResponseWriter, r *http.Request) {
218219
// handleCopyTo handles copying files from client to guest
219220
// Returns the number of bytes transferred and any error.
220221
func (s *ApiService) handleCopyTo(ctx context.Context, ws *websocket.Conn, inst *instances.Instance, req CpRequest) (int64, error) {
221-
grpcConn, err := guest.GetOrCreateConnPublic(ctx, inst.VsockSocket)
222+
// Create vsock dialer for this hypervisor type
223+
dialer, err := hypervisor.NewVsockDialer(inst.HypervisorType, inst.VsockSocket, inst.VsockCID)
224+
if err != nil {
225+
return 0, fmt.Errorf("create vsock dialer: %w", err)
226+
}
227+
228+
grpcConn, err := guest.GetOrCreateConn(ctx, dialer)
222229
if err != nil {
223230
return 0, fmt.Errorf("get grpc connection: %w", err)
224231
}
@@ -322,7 +329,13 @@ func (s *ApiService) handleCopyTo(ctx context.Context, ws *websocket.Conn, inst
322329
// handleCopyFrom handles copying files from guest to client
323330
// Returns the number of bytes transferred and any error.
324331
func (s *ApiService) handleCopyFrom(ctx context.Context, ws *websocket.Conn, inst *instances.Instance, req CpRequest) (int64, error) {
325-
grpcConn, err := guest.GetOrCreateConnPublic(ctx, inst.VsockSocket)
332+
// Create vsock dialer for this hypervisor type
333+
dialer, err := hypervisor.NewVsockDialer(inst.HypervisorType, inst.VsockSocket, inst.VsockCID)
334+
if err != nil {
335+
return 0, fmt.Errorf("create vsock dialer: %w", err)
336+
}
337+
338+
grpcConn, err := guest.GetOrCreateConn(ctx, dialer)
326339
if err != nil {
327340
return 0, fmt.Errorf("get grpc connection: %w", err)
328341
}
@@ -406,4 +419,3 @@ func (s *ApiService) handleCopyFrom(ctx context.Context, ws *websocket.Conn, ins
406419
}
407420
return bytesReceived, nil
408421
}
409-

cmd/api/api/cp_test.go

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"time"
99

1010
"github.com/onkernel/hypeman/lib/guest"
11+
"github.com/onkernel/hypeman/lib/hypervisor"
1112
"github.com/onkernel/hypeman/lib/oapi"
1213
"github.com/onkernel/hypeman/lib/paths"
1314
"github.com/onkernel/hypeman/lib/system"
@@ -109,10 +110,14 @@ func TestCpToAndFromInstance(t *testing.T) {
109110
err = os.WriteFile(srcFile, []byte(testContent), 0644)
110111
require.NoError(t, err)
111112

113+
// Create vsock dialer
114+
dialer, err := hypervisor.NewVsockDialer(actualInst.HypervisorType, actualInst.VsockSocket, actualInst.VsockCID)
115+
require.NoError(t, err)
116+
112117
// Test 1: Copy file TO instance
113118
t.Log("Testing CopyToInstance...")
114119
dstPath := "/tmp/copied-file.txt"
115-
err = guest.CopyToInstance(ctx(), actualInst.VsockSocket, guest.CopyToInstanceOptions{
120+
err = guest.CopyToInstance(ctx(), dialer, guest.CopyToInstanceOptions{
116121
SrcPath: srcFile,
117122
DstPath: dstPath,
118123
})
@@ -121,7 +126,7 @@ func TestCpToAndFromInstance(t *testing.T) {
121126
// Verify the file was copied by reading it back via exec
122127
t.Log("Verifying file was copied via exec...")
123128
var stdout, stderr outputBuffer
124-
exit, err := guest.ExecIntoInstance(ctx(), actualInst.VsockSocket, guest.ExecOptions{
129+
exit, err := guest.ExecIntoInstance(ctx(), dialer, guest.ExecOptions{
125130
Command: []string{"cat", dstPath},
126131
Stdout: &stdout,
127132
Stderr: &stderr,
@@ -134,7 +139,7 @@ func TestCpToAndFromInstance(t *testing.T) {
134139
// Test 2: Copy file FROM instance
135140
t.Log("Testing CopyFromInstance...")
136141
localDstDir := t.TempDir()
137-
err = guest.CopyFromInstance(ctx(), actualInst.VsockSocket, guest.CopyFromInstanceOptions{
142+
err = guest.CopyFromInstance(ctx(), dialer, guest.CopyFromInstanceOptions{
138143
SrcPath: dstPath,
139144
DstPath: localDstDir,
140145
})
@@ -211,6 +216,10 @@ func TestCpDirectoryToInstance(t *testing.T) {
211216
actualInst, err := svc.InstanceManager.GetInstance(ctx(), inst.Id)
212217
require.NoError(t, err)
213218

219+
// Create vsock dialer
220+
dialer, err := hypervisor.NewVsockDialer(actualInst.HypervisorType, actualInst.VsockSocket, actualInst.VsockCID)
221+
require.NoError(t, err)
222+
214223
// Create a test directory structure
215224
srcDir := filepath.Join(t.TempDir(), "testdir")
216225
require.NoError(t, os.MkdirAll(filepath.Join(srcDir, "subdir"), 0755))
@@ -219,15 +228,15 @@ func TestCpDirectoryToInstance(t *testing.T) {
219228

220229
// Copy directory to instance
221230
t.Log("Copying directory to instance...")
222-
err = guest.CopyToInstance(ctx(), actualInst.VsockSocket, guest.CopyToInstanceOptions{
231+
err = guest.CopyToInstance(ctx(), dialer, guest.CopyToInstanceOptions{
223232
SrcPath: srcDir,
224233
DstPath: "/tmp/testdir",
225234
})
226235
require.NoError(t, err)
227236

228237
// Verify files exist via exec
229238
var stdout outputBuffer
230-
exit, err := guest.ExecIntoInstance(ctx(), actualInst.VsockSocket, guest.ExecOptions{
239+
exit, err := guest.ExecIntoInstance(ctx(), dialer, guest.ExecOptions{
231240
Command: []string{"cat", "/tmp/testdir/file1.txt"},
232241
Stdout: &stdout,
233242
TTY: false,
@@ -237,7 +246,7 @@ func TestCpDirectoryToInstance(t *testing.T) {
237246
assert.Equal(t, "file1 content", stdout.String())
238247

239248
stdout = outputBuffer{}
240-
exit, err = guest.ExecIntoInstance(ctx(), actualInst.VsockSocket, guest.ExecOptions{
249+
exit, err = guest.ExecIntoInstance(ctx(), dialer, guest.ExecOptions{
241250
Command: []string{"cat", "/tmp/testdir/subdir/file2.txt"},
242251
Stdout: &stdout,
243252
TTY: false,
@@ -249,7 +258,7 @@ func TestCpDirectoryToInstance(t *testing.T) {
249258
// Copy directory from instance
250259
t.Log("Copying directory from instance...")
251260
localDstDir := t.TempDir()
252-
err = guest.CopyFromInstance(ctx(), actualInst.VsockSocket, guest.CopyFromInstanceOptions{
261+
err = guest.CopyFromInstance(ctx(), dialer, guest.CopyFromInstanceOptions{
253262
SrcPath: "/tmp/testdir",
254263
DstPath: localDstDir,
255264
})
@@ -266,4 +275,3 @@ func TestCpDirectoryToInstance(t *testing.T) {
266275

267276
t.Log("Directory cp tests passed!")
268277
}
269-

cmd/api/api/exec.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"github.com/gorilla/websocket"
1414
"github.com/onkernel/hypeman/lib/guest"
15+
"github.com/onkernel/hypeman/lib/hypervisor"
1516
"github.com/onkernel/hypeman/lib/instances"
1617
"github.com/onkernel/hypeman/lib/logger"
1718
mw "github.com/onkernel/hypeman/lib/middleware"
@@ -110,8 +111,17 @@ func (s *ApiService) ExecHandler(w http.ResponseWriter, r *http.Request) {
110111
// Create WebSocket read/writer wrapper
111112
wsConn := &wsReadWriter{ws: ws, ctx: ctx}
112113

114+
// Create vsock dialer for this hypervisor type
115+
dialer, err := hypervisor.NewVsockDialer(hypervisor.Type(inst.HypervisorType), inst.VsockSocket, inst.VsockCID)
116+
if err != nil {
117+
log.ErrorContext(ctx, "failed to create vsock dialer", "error", err)
118+
ws.WriteMessage(websocket.BinaryMessage, []byte(fmt.Sprintf("Error: %v\r\n", err)))
119+
ws.WriteMessage(websocket.TextMessage, []byte(`{"exitCode":127}`))
120+
return
121+
}
122+
113123
// Execute via vsock
114-
exit, err := guest.ExecIntoInstance(ctx, inst.VsockSocket, guest.ExecOptions{
124+
exit, err := guest.ExecIntoInstance(ctx, dialer, guest.ExecOptions{
115125
Command: execReq.Command,
116126
Stdin: wsConn,
117127
Stdout: wsConn,

cmd/api/api/exec_test.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"time"
99

1010
"github.com/onkernel/hypeman/lib/guest"
11+
"github.com/onkernel/hypeman/lib/hypervisor"
1112
"github.com/onkernel/hypeman/lib/instances"
1213
"github.com/onkernel/hypeman/lib/oapi"
1314
"github.com/onkernel/hypeman/lib/paths"
@@ -119,13 +120,16 @@ func TestExecInstanceNonTTY(t *testing.T) {
119120
var stdout, stderr outputBuffer
120121
var execErr error
121122

123+
dialer, err := hypervisor.NewVsockDialer(actualInst.HypervisorType, actualInst.VsockSocket, actualInst.VsockCID)
124+
require.NoError(t, err)
125+
122126
t.Log("Testing exec command: whoami")
123127
maxRetries := 10
124128
for i := 0; i < maxRetries; i++ {
125129
stdout = outputBuffer{}
126130
stderr = outputBuffer{}
127131

128-
exit, execErr = guest.ExecIntoInstance(ctx(), actualInst.VsockSocket, guest.ExecOptions{
132+
exit, execErr = guest.ExecIntoInstance(ctx(), dialer, guest.ExecOptions{
129133
Command: []string{"/bin/sh", "-c", "whoami"},
130134
Stdin: nil,
131135
Stdout: &stdout,
@@ -250,9 +254,12 @@ func TestExecWithDebianMinimal(t *testing.T) {
250254
assert.Contains(t, logs, "overlay-init: app exited with code", "App should have exited")
251255

252256
// Test exec commands work even though the main app (bash) has exited
257+
dialer2, err := hypervisor.NewVsockDialer(actualInst.HypervisorType, actualInst.VsockSocket, actualInst.VsockCID)
258+
require.NoError(t, err)
259+
253260
t.Log("Testing exec command: echo")
254261
var stdout, stderr outputBuffer
255-
exit, err := guest.ExecIntoInstance(ctx(), actualInst.VsockSocket, guest.ExecOptions{
262+
exit, err := guest.ExecIntoInstance(ctx(), dialer2, guest.ExecOptions{
256263
Command: []string{"echo", "hello from debian"},
257264
Stdout: &stdout,
258265
Stderr: &stderr,
@@ -266,7 +273,7 @@ func TestExecWithDebianMinimal(t *testing.T) {
266273
// Verify we're actually in Debian
267274
t.Log("Verifying OS release...")
268275
stdout = outputBuffer{}
269-
exit, err = guest.ExecIntoInstance(ctx(), actualInst.VsockSocket, guest.ExecOptions{
276+
exit, err = guest.ExecIntoInstance(ctx(), dialer2, guest.ExecOptions{
270277
Command: []string{"cat", "/etc/os-release"},
271278
Stdout: &stdout,
272279
TTY: false,

cmd/api/api/instances.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"github.com/c2h5oh/datasize"
1111
"github.com/onkernel/hypeman/lib/guest"
12+
"github.com/onkernel/hypeman/lib/hypervisor"
1213
"github.com/onkernel/hypeman/lib/instances"
1314
"github.com/onkernel/hypeman/lib/logger"
1415
mw "github.com/onkernel/hypeman/lib/middleware"
@@ -137,6 +138,12 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst
137138
}
138139
}
139140

141+
// Convert hypervisor type from API enum to domain type
142+
var hvType hypervisor.Type
143+
if request.Body.Hypervisor != nil {
144+
hvType = hypervisor.Type(*request.Body.Hypervisor)
145+
}
146+
140147
domainReq := instances.CreateInstanceRequest{
141148
Name: request.Body.Name,
142149
Image: request.Body.Image,
@@ -148,6 +155,7 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst
148155
NetworkEnabled: networkEnabled,
149156
Devices: deviceRefs,
150157
Volumes: volumes,
158+
Hypervisor: hvType,
151159
}
152160

153161
inst, err := s.InstanceManager.CreateInstance(ctx, domainReq)
@@ -452,8 +460,17 @@ func (s *ApiService) StatInstancePath(ctx context.Context, request oapi.StatInst
452460
}, nil
453461
}
454462

455-
// Connect to guest agent
456-
grpcConn, err := guest.GetOrCreateConnPublic(ctx, inst.VsockSocket)
463+
// Create vsock dialer for this hypervisor type
464+
dialer, err := hypervisor.NewVsockDialer(inst.HypervisorType, inst.VsockSocket, inst.VsockCID)
465+
if err != nil {
466+
log.ErrorContext(ctx, "failed to create vsock dialer", "error", err)
467+
return oapi.StatInstancePath500JSONResponse{
468+
Code: "internal_error",
469+
Message: "failed to create vsock dialer",
470+
}, nil
471+
}
472+
473+
grpcConn, err := guest.GetOrCreateConn(ctx, dialer)
457474
if err != nil {
458475
log.ErrorContext(ctx, "failed to get grpc connection", "error", err)
459476
return oapi.StatInstancePath500JSONResponse{
@@ -537,6 +554,9 @@ func instanceToOAPI(inst instances.Instance) oapi.Instance {
537554
netObj.Mac = lo.ToPtr(inst.MAC)
538555
}
539556

557+
// Convert hypervisor type
558+
hvType := oapi.InstanceHypervisor(inst.HypervisorType)
559+
540560
oapiInst := oapi.Instance{
541561
Id: inst.Id,
542562
Name: inst.Name,
@@ -552,6 +572,7 @@ func instanceToOAPI(inst instances.Instance) oapi.Instance {
552572
StartedAt: inst.StartedAt,
553573
StoppedAt: inst.StoppedAt,
554574
HasSnapshot: lo.ToPtr(inst.HasSnapshot),
575+
Hypervisor: &hvType,
555576
}
556577

557578
if len(inst.Env) > 0 {

cmd/api/config/config.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ type Config struct {
101101

102102
// Cloudflare configuration (if AcmeDnsProvider=cloudflare)
103103
CloudflareApiToken string // Cloudflare API token
104+
105+
// Hypervisor configuration
106+
DefaultHypervisor string // Default hypervisor type: "cloud-hypervisor" or "qemu"
104107
}
105108

106109
// Load loads configuration from environment variables
@@ -163,6 +166,9 @@ func Load() *Config {
163166

164167
// Cloudflare configuration
165168
CloudflareApiToken: getEnv("CLOUDFLARE_API_TOKEN", ""),
169+
170+
// Hypervisor configuration
171+
DefaultHypervisor: getEnv("DEFAULT_HYPERVISOR", "cloud-hypervisor"),
166172
}
167173

168174
return cfg

cmd/api/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/onkernel/hypeman/cmd/api/api"
2424
"github.com/onkernel/hypeman/cmd/api/config"
2525
"github.com/onkernel/hypeman/lib/guest"
26+
"github.com/onkernel/hypeman/lib/hypervisor/qemu"
2627
"github.com/onkernel/hypeman/lib/instances"
2728
mw "github.com/onkernel/hypeman/lib/middleware"
2829
"github.com/onkernel/hypeman/lib/oapi"
@@ -125,6 +126,11 @@ func run() error {
125126
}
126127
logger.Info("KVM access verified")
127128

129+
// Check if QEMU is available (optional - only warn if not present)
130+
if _, err := (&qemu.Starter{}).GetBinaryPath(nil, ""); err != nil {
131+
logger.Warn("QEMU not available - QEMU hypervisor will not work", "error", err)
132+
}
133+
128134
// Validate log rotation config
129135
var logMaxSize datasize.ByteSize
130136
if err := logMaxSize.UnmarshalText([]byte(app.Config.LogMaxSize)); err != nil {

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ require (
66
github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500
77
github.com/creack/pty v1.1.24
88
github.com/cyphar/filepath-securejoin v0.6.1
9+
github.com/digitalocean/go-qemu v0.0.0-20250212194115-ee9b0668d242
910
github.com/distribution/reference v0.6.0
1011
github.com/getkin/kin-openapi v0.133.0
1112
github.com/ghodss/yaml v1.0.0
@@ -58,6 +59,7 @@ require (
5859
github.com/containerd/errdefs/pkg v0.3.0 // indirect
5960
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
6061
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
62+
github.com/digitalocean/go-libvirt v0.0.0-20220804181439-8648fbde413e // indirect
6163
github.com/docker/cli v28.2.2+incompatible // indirect
6264
github.com/docker/distribution v2.8.3+incompatible // indirect
6365
github.com/docker/docker v28.2.2+incompatible // indirect

0 commit comments

Comments
 (0)