Skip to content

Commit f0dfa1f

Browse files
JAORMXclaude
andcommitted
Add hypervisor.Backend abstraction layer
Introduce a pluggable hypervisor.Backend interface that decouples the VM lifecycle from the libkrun runner implementation. The default backend (hypervisor/libkrun) wraps the existing runner.Spawner and runner.ProcessHandle behind the new Backend/VMHandle interfaces. Breaking changes: - Remove WithRunnerPath, WithLibDir, WithSpawner from propolis options - Replace VM.PID() int with VM.ID() string - Add WithBackend option accepting hypervisor.Backend Security hardening: - Validate PrepareRootFS return path stays within rootfs boundary - pidFromID returns error on non-numeric, zero, or negative values - Eliminate TOCTOU race in VM.Remove by always calling Stop - Document VMHandle concurrency safety and idempotency contracts Closes #1 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9b06e02 commit f0dfa1f

13 files changed

Lines changed: 837 additions & 186 deletions
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# Migration Guide: hypervisor.Backend Abstraction
2+
3+
This document covers the breaking changes introduced by the
4+
`hypervisor.Backend` abstraction layer and how to migrate downstream
5+
consumers.
6+
7+
## Breaking Changes
8+
9+
| Removed API | Replacement | Affected projects |
10+
|---|---|---|
11+
| `propolis.WithRunnerPath(p)` | `libkrun.WithRunnerPath(p)` passed to `libkrun.NewBackend()` | waggle, apiary, toolhive-appliance |
12+
| `propolis.WithLibDir(d)` | `libkrun.WithLibDir(d)` passed to `libkrun.NewBackend()` | waggle, toolhive-appliance |
13+
| `propolis.WithSpawner(s)` | `libkrun.WithSpawner(s)` passed to `libkrun.NewBackend()` | tests only |
14+
| `propolis.VM.PID() int` | `propolis.VM.ID() string` | waggle, toolhive-appliance |
15+
16+
## New Imports
17+
18+
```go
19+
import "github.com/stacklok/propolis/hypervisor/libkrun"
20+
```
21+
22+
## Migration Examples
23+
24+
### waggle (`pkg/infra/vm/propolis.go`)
25+
26+
**Before:**
27+
```go
28+
if opts.RunnerPath != "" {
29+
propolisOpts = append(propolisOpts, propolis.WithRunnerPath(opts.RunnerPath))
30+
}
31+
if opts.LibDir != "" {
32+
propolisOpts = append(propolisOpts, propolis.WithLibDir(opts.LibDir))
33+
}
34+
slog.Info("microVM created", "pid", vm.PID())
35+
```
36+
37+
**After:**
38+
```go
39+
import "github.com/stacklok/propolis/hypervisor/libkrun"
40+
41+
var backendOpts []libkrun.Option
42+
if opts.RunnerPath != "" {
43+
backendOpts = append(backendOpts, libkrun.WithRunnerPath(opts.RunnerPath))
44+
}
45+
if opts.LibDir != "" {
46+
backendOpts = append(backendOpts, libkrun.WithLibDir(opts.LibDir))
47+
}
48+
propolisOpts = append(propolisOpts, propolis.WithBackend(libkrun.NewBackend(backendOpts...)))
49+
slog.Info("microVM created", "id", vm.ID())
50+
```
51+
52+
### apiary (`internal/infra/vm/runner.go`)
53+
54+
**Before:**
55+
```go
56+
if r.runnerPath != "" {
57+
opts = append(opts, propolis.WithRunnerPath(r.runnerPath))
58+
}
59+
```
60+
61+
**After:**
62+
```go
63+
import "github.com/stacklok/propolis/hypervisor/libkrun"
64+
65+
if r.runnerPath != "" {
66+
opts = append(opts, propolis.WithBackend(libkrun.NewBackend(
67+
libkrun.WithRunnerPath(r.runnerPath),
68+
)))
69+
}
70+
```
71+
72+
### toolhive-appliance (`internal/vm/libkrun/manager_cgo.go`)
73+
74+
**Before:**
75+
```go
76+
if runnerPath != "" {
77+
propolisOpts = append(propolisOpts, propolis.WithRunnerPath(runnerPath))
78+
}
79+
if libDir != "" {
80+
propolisOpts = append(propolisOpts, propolis.WithLibDir(libDir))
81+
}
82+
PID: vmInstance.PID(),
83+
go m.reaperLoop(vmInstance.PID())
84+
```
85+
86+
**After:**
87+
```go
88+
import (
89+
"strconv"
90+
"github.com/stacklok/propolis/hypervisor/libkrun"
91+
)
92+
93+
var backendOpts []libkrun.Option
94+
if runnerPath != "" {
95+
backendOpts = append(backendOpts, libkrun.WithRunnerPath(runnerPath))
96+
}
97+
if libDir != "" {
98+
backendOpts = append(backendOpts, libkrun.WithLibDir(libDir))
99+
}
100+
propolisOpts = append(propolisOpts, propolis.WithBackend(libkrun.NewBackend(backendOpts...)))
101+
102+
// For PID — parse ID string:
103+
id := vmInstance.ID()
104+
pid, _ := strconv.Atoi(id)
105+
// Use pid for state and reaper loop
106+
```
107+
108+
### Test code using `WithSpawner`
109+
110+
**Before:**
111+
```go
112+
spawner := &mockSpawner{proc: mockProc, err: nil}
113+
opts := []propolis.Option{
114+
propolis.WithSpawner(spawner),
115+
}
116+
```
117+
118+
**After:**
119+
```go
120+
// Implement hypervisor.Backend directly for test mocks:
121+
type mockBackend struct {
122+
handle hypervisor.VMHandle
123+
err error
124+
}
125+
126+
func (m *mockBackend) Name() string { return "mock" }
127+
func (m *mockBackend) PrepareRootFS(_ context.Context, p string, _ hypervisor.InitConfig) (string, error) {
128+
return p, nil
129+
}
130+
func (m *mockBackend) Start(_ context.Context, _ hypervisor.VMConfig) (hypervisor.VMHandle, error) {
131+
return m.handle, m.err
132+
}
133+
134+
opts := []propolis.Option{
135+
propolis.WithBackend(&mockBackend{handle: mockHandle}),
136+
}
137+
```

hypervisor/backend.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package hypervisor
5+
6+
import "context"
7+
8+
// Backend abstracts the hypervisor used to run a microVM.
9+
// Each implementation is responsible for preparing the root filesystem
10+
// and starting the VM through its own mechanism.
11+
type Backend interface {
12+
// Name returns a short identifier for this backend (e.g. "libkrun").
13+
Name() string
14+
// PrepareRootFS applies any backend-specific setup to the rootfs
15+
// directory (e.g. writing .krun_config.json for libkrun). It
16+
// returns the path to use as the VM root filesystem, which must
17+
// be equal to or within rootfsPath. Returning a path outside
18+
// rootfsPath is a contract violation that callers will reject.
19+
PrepareRootFS(ctx context.Context, rootfsPath string, initCfg InitConfig) (string, error)
20+
// Start launches the VM and returns a handle for lifecycle management.
21+
// If Start returns an error, the backend must have cleaned up any
22+
// partial state (e.g. killed partially-spawned processes). Callers
23+
// must not attempt recovery from partial start failures.
24+
Start(ctx context.Context, cfg VMConfig) (VMHandle, error)
25+
}
26+
27+
// VMHandle provides lifecycle control over a running VM.
28+
// All methods must be safe for concurrent use.
29+
type VMHandle interface {
30+
// Stop gracefully shuts down the VM. Stop must be idempotent:
31+
// calling it on an already-stopped VM returns nil.
32+
Stop(ctx context.Context) error
33+
// IsAlive reports whether the VM is still running.
34+
IsAlive() bool
35+
// ID returns a backend-specific identifier for the VM (e.g. PID
36+
// for process-based backends, instance ID for cloud backends).
37+
// The returned value must be stable across calls.
38+
ID() string
39+
}
40+
41+
// VMConfig contains all parameters needed to start a VM.
42+
type VMConfig struct {
43+
Name string
44+
RootFSPath string
45+
NumVCPUs uint32
46+
RAMMiB uint32
47+
PortForwards []PortForward
48+
FilesystemMounts []FilesystemMount
49+
InitConfig InitConfig
50+
DataDir string
51+
ConsoleLogPath string
52+
NetEndpoint NetEndpoint
53+
}
54+
55+
// InitConfig describes the process to run inside the VM.
56+
type InitConfig struct {
57+
Cmd []string
58+
Env []string
59+
WorkingDir string
60+
}
61+
62+
// NetEndpoint describes how the VM connects to the network.
63+
type NetEndpoint struct {
64+
Type NetEndpointType
65+
Path string
66+
}
67+
68+
// NetEndpointType enumerates supported network transport mechanisms.
69+
type NetEndpointType int
70+
71+
const (
72+
// NetEndpointNone means no external network endpoint is configured.
73+
NetEndpointNone NetEndpointType = iota
74+
// NetEndpointUnixSocket connects via a Unix domain socket.
75+
NetEndpointUnixSocket
76+
// NetEndpointNamedPipe connects via a named pipe (Windows).
77+
NetEndpointNamedPipe
78+
// NetEndpointHVSocket connects via a Hyper-V socket.
79+
NetEndpointHVSocket
80+
)
81+
82+
// PortForward maps a host port to a guest port.
83+
type PortForward struct {
84+
Host uint16
85+
Guest uint16
86+
}
87+
88+
// FilesystemMount exposes a host directory to the guest.
89+
type FilesystemMount struct {
90+
Tag string
91+
HostPath string
92+
}

hypervisor/doc.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Package hypervisor defines the Backend interface for pluggable VM
5+
// hypervisor implementations. The default implementation is libkrun,
6+
// provided by the hypervisor/libkrun sub-package.
7+
package hypervisor

hypervisor/libkrun/backend.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package libkrun
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"path/filepath"
10+
11+
"github.com/stacklok/propolis/hypervisor"
12+
"github.com/stacklok/propolis/image"
13+
"github.com/stacklok/propolis/runner"
14+
)
15+
16+
// Option configures a libkrun Backend.
17+
type Option func(*Backend)
18+
19+
// WithRunnerPath sets the path to the propolis-runner binary.
20+
// When empty, the runner is found via $PATH or alongside the calling binary.
21+
func WithRunnerPath(p string) Option { return func(b *Backend) { b.runnerPath = p } }
22+
23+
// WithLibDir sets the path to a directory containing libkrun/libkrunfw
24+
// shared libraries. The runner subprocess will use this via LD_LIBRARY_PATH.
25+
func WithLibDir(d string) Option { return func(b *Backend) { b.libDir = d } }
26+
27+
// WithSpawner sets a custom spawner for the runner subprocess.
28+
// When nil (default), the standard runner.DefaultSpawner is used.
29+
func WithSpawner(s runner.Spawner) Option { return func(b *Backend) { b.spawner = s } }
30+
31+
// Backend implements hypervisor.Backend using libkrun.
32+
type Backend struct {
33+
runnerPath string
34+
libDir string
35+
spawner runner.Spawner
36+
}
37+
38+
// NewBackend creates a libkrun backend with the given options.
39+
func NewBackend(opts ...Option) *Backend {
40+
b := &Backend{}
41+
for _, o := range opts {
42+
o(b)
43+
}
44+
return b
45+
}
46+
47+
// Name returns "libkrun".
48+
func (b *Backend) Name() string { return "libkrun" }
49+
50+
// PrepareRootFS writes .krun_config.json into the rootfs directory.
51+
func (b *Backend) PrepareRootFS(_ context.Context, rootfsPath string, initCfg hypervisor.InitConfig) (string, error) {
52+
kc := image.KrunConfig{
53+
Cmd: initCfg.Cmd,
54+
Env: initCfg.Env,
55+
WorkingDir: initCfg.WorkingDir,
56+
}
57+
if err := kc.WriteTo(rootfsPath); err != nil {
58+
return "", fmt.Errorf("write krun config: %w", err)
59+
}
60+
return rootfsPath, nil
61+
}
62+
63+
// Start launches the VM via the propolis-runner subprocess.
64+
func (b *Backend) Start(ctx context.Context, cfg hypervisor.VMConfig) (hypervisor.VMHandle, error) {
65+
var netSocket string
66+
if cfg.NetEndpoint.Type == hypervisor.NetEndpointUnixSocket {
67+
netSocket = cfg.NetEndpoint.Path
68+
}
69+
70+
runCfg := runner.Config{
71+
RootPath: cfg.RootFSPath,
72+
NumVCPUs: cfg.NumVCPUs,
73+
RAMMiB: cfg.RAMMiB,
74+
NetSocket: netSocket,
75+
PortForwards: toRunnerPortForwards(cfg.PortForwards),
76+
VirtioFS: toRunnerVirtioFS(cfg.FilesystemMounts),
77+
ConsoleLog: cfg.ConsoleLogPath,
78+
LibDir: b.libDir,
79+
RunnerPath: b.runnerPath,
80+
VMLogPath: filepath.Join(cfg.DataDir, "vm.log"),
81+
}
82+
83+
spawner := b.spawner
84+
if spawner == nil {
85+
spawner = runner.DefaultSpawner{}
86+
}
87+
88+
proc, err := spawner.Spawn(ctx, runCfg)
89+
if err != nil {
90+
return nil, fmt.Errorf("spawn runner: %w", err)
91+
}
92+
93+
return &processHandle{proc: proc}, nil
94+
}
95+
96+
func toRunnerPortForwards(ports []hypervisor.PortForward) []runner.PortForward {
97+
out := make([]runner.PortForward, len(ports))
98+
for i, p := range ports {
99+
out[i] = runner.PortForward{Host: p.Host, Guest: p.Guest}
100+
}
101+
return out
102+
}
103+
104+
func toRunnerVirtioFS(mounts []hypervisor.FilesystemMount) []runner.VirtioFSMount {
105+
out := make([]runner.VirtioFSMount, len(mounts))
106+
for i, m := range mounts {
107+
out[i] = runner.VirtioFSMount{Tag: m.Tag, HostPath: m.HostPath}
108+
}
109+
return out
110+
}

0 commit comments

Comments
 (0)