Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
6e2e90c
Generalize vGPU device lifecycle
yummybomb Jul 28, 2026
2b4be15
Harden vGPU lifecycle compatibility
yummybomb Jul 29, 2026
6cd7d87
Validate unsupported vGPU frameworks on Darwin
yummybomb Jul 29, 2026
44e960f
Reduce vGPU refactor diff noise
yummybomb Jul 29, 2026
19b2197
Leave vGPU hypervisor selection to callers
yummybomb Jul 29, 2026
01fda7f
Require qemu for vGPU instances
yummybomb Jul 29, 2026
8b8dd24
Preserve vGPU hypervisor support
yummybomb Jul 29, 2026
5cad940
Track VF allocation with a single field
yummybomb Aug 4, 2026
851944b
Update vGPU lifecycle comments
yummybomb Aug 5, 2026
1b675d3
Drop the dead mdev branch from QEMU PCI passthrough args
yummybomb Aug 5, 2026
be99687
Fix Darwin vGPU build
yummybomb Aug 6, 2026
65c9f1b
Preserve QEMU vGPU device ordering
yummybomb Aug 6, 2026
2662fdd
Preserve mdev-era lifecycle semantics in the refactor
yummybomb Aug 6, 2026
4499016
Keep the mdev create error text in the refactor
yummybomb Aug 7, 2026
12bd9d9
Restore mdev start error text
yummybomb Aug 9, 2026
e309787
Release stale vGPU assignments on start and retain assignments on fai…
yummybomb Aug 6, 2026
6e9680b
Release the vGPU before other teardown on delete and let stopped inst…
yummybomb Aug 5, 2026
631c9ce
Keep stop's no-op contract on failed retained vGPU release and pass a…
yummybomb Aug 5, 2026
4b235fb
Keep retained vGPU assignments out of instance forks
yummybomb Aug 5, 2026
bbcc992
Persist a stale vGPU release during start immediately
yummybomb Aug 5, 2026
eecebb8
Clear vGPU assignments from snapshot forks
yummybomb Aug 6, 2026
807aced
Preserve current vGPU assignment across snapshot restore
yummybomb Aug 7, 2026
830ad44
Document vGPU rollback alongside the retention behavior
yummybomb Aug 7, 2026
2cdc587
Block restart policy before delete teardown
yummybomb Aug 8, 2026
c91c818
Unify hypervisor liveness checks on ProcessExists
yummybomb Aug 6, 2026
9db1c19
Wait for non-child hypervisor exit before finishing kill
yummybomb Aug 6, 2026
d8a8a42
Verify socket ownership before treating a hypervisor PID as live
yummybomb Aug 6, 2026
799d780
Fail closed on hypervisor liveness checks
yummybomb Aug 6, 2026
d46ccb7
Fail closed on duplicate socket paths
yummybomb Aug 6, 2026
9973643
Resolve socket owner from listening entries only
yummybomb Aug 7, 2026
bcf0ab7
Verify socket ownership before force-killing a hypervisor PID
yummybomb Aug 7, 2026
daabd83
Skip hypervisor kill when socket ownership is unconfirmed
yummybomb Aug 8, 2026
405ff78
Fail delete when hypervisor ownership is unconfirmed
yummybomb Aug 9, 2026
f17f33e
Verify hypervisor ownership before killing
yummybomb Aug 9, 2026
72a4f68
Support vendor VFIO vGPU devices
yummybomb Aug 6, 2026
ef8a46f
Account for consumed vGPU profiles
yummybomb Aug 6, 2026
97b9166
Reject unowned vendor VFIO releases
yummybomb Aug 6, 2026
835e695
Check all vendor VFIO device paths
yummybomb Aug 6, 2026
6653316
Require usable mdev types for discovery
yummybomb Aug 6, 2026
872731c
Test retained vendor VFIO assignments
yummybomb Aug 6, 2026
9c9936c
Always delete the test instance during vGPU cleanup
yummybomb Aug 6, 2026
5e4d269
Fall back to passthrough status when vGPU discovery fails
yummybomb Aug 6, 2026
624069a
Keep vGPU placement available when an allocated type is unknown
yummybomb Aug 7, 2026
4e53bdb
Report vendor VFIO availability per parent GPU and thread instance ow…
yummybomb Aug 8, 2026
d2bf3fa
Keep vendor VFIO out of the create path until lifecycle integration
yummybomb Aug 9, 2026
ad506fd
Guard vGPU releases with live-instance claims
yummybomb Aug 6, 2026
187710c
Reconcile vendor VFIO vGPUs against a fail-closed instance inventory
yummybomb Aug 6, 2026
efcdf1f
Fail closed on vGPU claim checks
yummybomb Aug 6, 2026
d756f43
Retain only vGPU assignment after failed create
yummybomb Aug 6, 2026
4b772f1
Clear released vGPU assignment on start rollback
yummybomb Aug 6, 2026
e7e2f25
Test start rollback vGPU cleanup
yummybomb Aug 6, 2026
878b59e
Normalize legacy mdev paths in live-claim check
yummybomb Aug 7, 2026
06e4bc7
Bind the live-claimant test socket under /tmp for macOS
yummybomb Aug 7, 2026
fa4cdfa
Surface retained vGPU cleanup through a typed create error and manage…
yummybomb Aug 7, 2026
81b1276
Generalize the create vGPU error text
yummybomb Aug 7, 2026
592818a
Scope vGPU claim scan to vendor VFIO and close reconcile gaps
yummybomb Aug 8, 2026
957ef7b
Harden the vendor VFIO release path
yummybomb Aug 9, 2026
1ecd698
Fail closed on retained vGPU cleanup
yummybomb Aug 9, 2026
7ab91a5
Harden vGPU cleanup failure handling
yummybomb Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions cmd/api/api/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,16 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst

inst, err := s.InstanceManager.CreateInstance(ctx, domainReq)
if err != nil {
var vgpuPending *instances.VGPUCleanupPendingError
switch {
// Checked first: it wraps the original create error, so a later
// errors.Is case would match the cause and hide the retained instance.
case errors.As(err, &vgpuPending):
log.ErrorContext(ctx, "failed to create instance", "error", err, "image", request.Body.Image)
return oapi.CreateInstance500JSONResponse{
Code: "vgpu_cleanup_pending",
Message: fmt.Sprintf("failed to create instance; vGPU release failed during rollback and instance %s retains the assignment, delete it to retry", vgpuPending.InstanceID),
}, nil
case errors.Is(err, instances.ErrImageNotReady):
return oapi.CreateInstance400JSONResponse{
Code: "image_not_ready",
Expand Down
31 changes: 31 additions & 0 deletions cmd/api/api/instances_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/kernel/hypeman/lib/instances"
"github.com/kernel/hypeman/lib/instances/phasetracking"
mw "github.com/kernel/hypeman/lib/middleware"
"github.com/kernel/hypeman/lib/network"
"github.com/kernel/hypeman/lib/oapi"
"github.com/kernel/hypeman/lib/paths"
restartpolicy "github.com/kernel/hypeman/lib/restart-policy"
Expand Down Expand Up @@ -46,6 +47,36 @@ func TestGetInstance_NotFound(t *testing.T) {
require.Error(t, err)
}

type createErrorInstanceManager struct {
instances.Manager
err error
}

func (m createErrorInstanceManager) CreateInstance(context.Context, instances.CreateInstanceRequest) (*instances.Instance, error) {
return nil, m.err
}

// A retained-assignment error must win over the mapping of the create error
// it wraps, or the response omits the instance the caller has to delete.
func TestCreateInstance_VGPUCleanupPendingBeatsWrappedErrorMapping(t *testing.T) {
t.Parallel()
svc := newTestService(t)
svc.InstanceManager = createErrorInstanceManager{err: &instances.VGPUCleanupPendingError{
InstanceID: "inst-1",
Err: network.ErrNameExists,
}}

resp, err := svc.CreateInstance(ctx(), oapi.CreateInstanceRequestObject{
Body: &oapi.CreateInstanceRequest{Image: "test-image"},
})
require.NoError(t, err)

pending, ok := resp.(oapi.CreateInstance500JSONResponse)
require.True(t, ok, "expected 500 vgpu_cleanup_pending, got %T", resp)
assert.EqualValues(t, "vgpu_cleanup_pending", pending.Code)
assert.Contains(t, pending.Message, "inst-1")
}

func TestCreateInstance_AutoPullImage(t *testing.T) {
t.Parallel()
if _, err := os.Stat("/dev/kvm"); os.IsNotExist(err) {
Expand Down
37 changes: 32 additions & 5 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,28 @@ func configureUFFDGraduationController(cfg *config.Config, instanceManager insta
}, logger), nil
}

func liveInstanceVGPUDevicePaths(ctx context.Context, instanceManager instances.Manager) (map[string]struct{}, error) {
allInstances, err := instanceManager.ListInstancesForReconcile(ctx)
if err != nil {
return nil, err
}
protected := make(map[string]struct{})
for _, inst := range allInstances {
if inst.GPUDevicePath == "" {
continue
}
// A nil PID does not mean the assignment is orphaned: the PID is
// persisted only after the hypervisor starts, so a crash during boot
// leaves the device path without one. Only skip protection when the
// recorded hypervisor is known to be gone.
if inst.HypervisorPID != nil && !instances.HypervisorProcessExists(*inst.HypervisorPID, inst.SocketPath) {
continue
}
protected[inst.GPUDevicePath] = struct{}{}
}
return protected, nil
}

func run() error {
// Load config early for OTel initialization
// Config path can be specified via CONFIG_PATH env var or defaults to platform-specific locations
Expand Down Expand Up @@ -362,11 +384,16 @@ func run() error {
return fmt.Errorf("reconcile device state: %w", err)
}

// Reconcile mdev devices (clears orphaned vGPUs from previous runs)
logger.Info("Reconciling mdev devices...")
if err := devices.ReconcileMdevs(app.Ctx, nil); err != nil {
// Log but don't fail - mdev cleanup is best-effort
logger.Warn("failed to reconcile mdev devices", "error", err)
// Reconcile vGPU devices (clears orphaned vGPUs from previous runs)
logger.Info("Reconciling vGPU devices...")
protected, err := liveInstanceVGPUDevicePaths(app.Ctx, app.InstanceManager)
if err != nil {
logger.Warn("failed to list instances for vGPU reconcile protection; skipping vendor VFIO reconciliation", "error", err)
protected = nil
}
if err := devices.ReconcileVGPUs(app.Ctx, protected); err != nil {
// Log but don't fail - vGPU cleanup is best-effort
logger.Warn("failed to reconcile vGPU devices", "error", err)
}

// Wire up resource validator for aggregate limit checking
Expand Down
30 changes: 30 additions & 0 deletions cmd/api/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@ package main

import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"net/url"
"os/exec"
"testing"
"time"

"github.com/getkin/kin-openapi/openapi3filter"
"github.com/go-chi/chi/v5"
"github.com/golang-jwt/jwt/v5"
"github.com/kernel/hypeman/lib/instances"
mw "github.com/kernel/hypeman/lib/middleware"
"github.com/kernel/hypeman/lib/oapi"
nethttpmiddleware "github.com/oapi-codegen/nethttp-middleware"
Expand Down Expand Up @@ -338,3 +341,30 @@ func TestImageNameWithSlashes_URLEncoding(t *testing.T) {
})
}
}

type vgpuReconcileManagerStub struct {
instances.Manager
list []instances.Instance
}

func (s vgpuReconcileManagerStub) ListInstancesForReconcile(context.Context) ([]instances.Instance, error) {
return s.list, nil
}

// The hypervisor PID is persisted only after boot, so an assignment without
// one may belong to a VM that is still starting and must stay protected.
func TestLiveInstanceVGPUDevicePathsProtectsAssignmentsWithoutPID(t *testing.T) {
dead := exec.Command("true")
require.NoError(t, dead.Run())
deadPID := dead.Process.Pid

manager := vgpuReconcileManagerStub{list: []instances.Instance{
{StoredMetadata: instances.StoredMetadata{Id: "booting", GPUDevicePath: "/sys/bus/pci/devices/0000:82:00.4"}},
{StoredMetadata: instances.StoredMetadata{Id: "dead", GPUDevicePath: "/sys/bus/pci/devices/0000:82:00.5", HypervisorPID: &deadPID}},
}}

protected, err := liveInstanceVGPUDevicePaths(context.Background(), manager)
require.NoError(t, err)
assert.Contains(t, protected, "/sys/bus/pci/devices/0000:82:00.4")
assert.NotContains(t, protected, "/sys/bus/pci/devices/0000:82:00.5")
}
122 changes: 98 additions & 24 deletions integration/vgpu_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"

Expand All @@ -21,21 +23,23 @@ import (
"github.com/stretchr/testify/require"
)

// TestVGPU is an integration test that verifies vGPU (SR-IOV mdev) support works.
// TestVGPU is an integration test that verifies vGPU (SR-IOV) support works
// on the host's framework: mdev or NVIDIA's vendor-specific VFIO.
//
// This test automatically detects vGPU availability and skips if:
// - No SR-IOV VFs are found in /sys/class/mdev_bus/
// - No vGPU framework (mdev or vendor VFIO) is discovered
// - No vGPU profiles are available
// - Not running as root (required for mdev creation)
// - Not running as root (required for sysfs vGPU assignment)
// - KVM is not available
//
// To run manually:
//
// sudo go test -v -run TestVGPU -timeout 5m ./integration/...
//
// Note: This test verifies mdev creation and PCI device visibility inside the VM.
// It does NOT test nvidia-smi or CUDA functionality since that requires NVIDIA
// guest drivers pre-installed in the image.
// Note: This test verifies vGPU assignment, release on stop, reacquisition on
// start, and PCI device visibility inside the VM. It does NOT test nvidia-smi
// or CUDA functionality since that requires NVIDIA guest drivers pre-installed
// in the image.
func TestVGPU(t *testing.T) {
t.Parallel()
if testing.Short() {
Expand Down Expand Up @@ -84,9 +88,15 @@ func TestVGPU(t *testing.T) {

// Cleanup any orphaned instances and mdevs
t.Cleanup(func() {
if instanceID != "" {
t.Log("Cleanup: Deleting instance...")
instanceManager.DeleteInstance(ctx, instanceID)
if instanceID == "" {
return
}
if _, err := instanceManager.StopInstance(ctx, instanceID); err != nil {
t.Logf("Cleanup: stop instance: %v", err)
}
t.Log("Cleanup: Deleting instance...")
if err := instanceManager.DeleteInstance(ctx, instanceID); err != nil {
t.Errorf("cleanup: delete instance: %v", err)
}
})

Expand Down Expand Up @@ -159,9 +169,18 @@ func TestVGPU(t *testing.T) {
instanceID = inst.Id
t.Logf("Instance created: %s", inst.Id)

// Verify mdev UUID was assigned
require.NotEmpty(t, inst.GPUMdevUUID, "Instance should have mdev UUID assigned")
t.Logf("mdev UUID: %s", inst.GPUMdevUUID)
// Verify the assignment matches the host's framework
require.NotEmpty(t, inst.GPUDevicePath, "Instance should have a vGPU device path assigned")
switch inst.GPUFramework {
case devices.VGPUFrameworkMdev:
require.NotEmpty(t, inst.GPUMdevUUID, "mdev instance should have a UUID assigned")
t.Logf("mdev UUID: %s", inst.GPUMdevUUID)
case devices.VGPUFrameworkVendorVFIO:
require.Empty(t, inst.GPUMdevUUID, "vendor VFIO instance should not have an mdev UUID")
t.Logf("vendor VFIO VF: %s", inst.GPUDevicePath)
default:
t.Fatalf("unexpected vGPU framework %q", inst.GPUFramework)
}

// Step 5: Check GPU resources AFTER creating instance
t.Run("ResourcesDecrementedAfterCreation", func(t *testing.T) {
Expand All @@ -180,12 +199,9 @@ func TestVGPU(t *testing.T) {
assert.Less(t, availableAfter, availableBefore, "available instances should decrease after creating VM")
})

// Step 6: Verify mdev was created in sysfs
t.Run("MdevCreated", func(t *testing.T) {
mdevPath := "/sys/bus/mdev/devices/" + inst.GPUMdevUUID
_, err := os.Stat(mdevPath)
assert.NoError(t, err, "mdev device should exist at %s", mdevPath)
t.Logf("mdev exists at: %s", mdevPath)
// Step 6: Verify the assignment exists in sysfs
t.Run("VGPUAssignedInSysfs", func(t *testing.T) {
assertVGPUAssigned(t, inst.GPUFramework, inst.GPUDevicePath)
})

// Step 7: Wait for guest agent to be ready
Expand Down Expand Up @@ -225,13 +241,68 @@ func TestVGPU(t *testing.T) {
require.NoError(t, err)

assert.Equal(t, profile, actualInst.GPUProfile, "GPU profile should match")
assert.NotEmpty(t, actualInst.GPUMdevUUID, "mdev UUID should be set")
t.Logf("Instance GPU: profile=%s, mdev=%s", actualInst.GPUProfile, actualInst.GPUMdevUUID)
assert.Equal(t, inst.GPUFramework, actualInst.GPUFramework, "framework should match")
assert.NotEmpty(t, actualInst.GPUDevicePath, "device path should be set")
if inst.GPUFramework == devices.VGPUFrameworkMdev {
assert.NotEmpty(t, actualInst.GPUMdevUUID, "mdev UUID should be set")
}
t.Logf("Instance GPU: profile=%s, framework=%s, device=%s", actualInst.GPUProfile, actualInst.GPUFramework, actualInst.GPUDevicePath)
})

t.Log("Step 10: Stopping instance...")
_, err = instanceManager.StopInstance(ctx, inst.Id)
require.NoError(t, err, "stop should succeed")

t.Run("VGPUReleasedOnStop", func(t *testing.T) {
stopped, err := instanceManager.GetInstance(ctx, inst.Id)
require.NoError(t, err)
assert.Empty(t, stopped.GPUDevicePath, "assignment metadata should be cleared on stop")
assertVGPUReleased(t, inst.GPUFramework, inst.GPUDevicePath)
})

t.Log("Step 11: Starting instance to reacquire a vGPU...")
started, err := instanceManager.StartInstance(ctx, inst.Id, instances.StartInstanceRequest{})
require.NoError(t, err, "start should succeed")

t.Run("VGPUReacquiredOnStart", func(t *testing.T) {
require.NotEmpty(t, started.GPUDevicePath, "start should assign a vGPU")
assert.Equal(t, inst.GPUFramework, started.GPUFramework, "framework should match")
assertVGPUAssigned(t, started.GPUFramework, started.GPUDevicePath)
})

t.Log("✅ vGPU test PASSED!")
}

func assertVGPUAssigned(t *testing.T, framework devices.VGPUFramework, devicePath string) {
t.Helper()
switch framework {
case devices.VGPUFrameworkMdev:
_, err := os.Stat(devicePath)
assert.NoError(t, err, "mdev device should exist at %s", devicePath)
case devices.VGPUFrameworkVendorVFIO:
data, err := os.ReadFile(filepath.Join(devicePath, "nvidia", "current_vgpu_type"))
require.NoError(t, err, "VF should expose current_vgpu_type")
assert.NotEqual(t, "0", strings.TrimSpace(string(data)), "VF should have a vGPU type assigned")
default:
t.Fatalf("unexpected vGPU framework %q", framework)
}
}

func assertVGPUReleased(t *testing.T, framework devices.VGPUFramework, devicePath string) {
t.Helper()
switch framework {
case devices.VGPUFrameworkMdev:
_, err := os.Stat(devicePath)
assert.True(t, os.IsNotExist(err), "mdev device should be gone from %s", devicePath)
case devices.VGPUFrameworkVendorVFIO:
data, err := os.ReadFile(filepath.Join(devicePath, "nvidia", "current_vgpu_type"))
require.NoError(t, err, "VF should expose current_vgpu_type")
assert.Equal(t, "0", strings.TrimSpace(string(data)), "VF assignment should be released")
default:
t.Fatalf("unexpected vGPU framework %q", framework)
}
}

// checkVGPUTestPrerequisites checks if vGPU test can run.
// Returns (skipReason, profileName) - skipReason is empty if all prerequisites are met.
func checkVGPUTestPrerequisites() (string, string) {
Expand All @@ -245,10 +316,13 @@ func checkVGPUTestPrerequisites() (string, string) {
return "vGPU test requires root (sudo) for mdev creation", ""
}

// Check for vGPU mode (SR-IOV VFs present)
mode := devices.DetectHostGPUMode()
if mode != devices.GPUModeVGPU {
return "vGPU test requires SR-IOV VFs in /sys/class/mdev_bus/", ""
// Check for a vGPU framework (mdev or vendor VFIO)
framework, _, err := devices.DiscoverVGPU()
if err != nil {
return "vGPU test failed to discover vGPU framework: " + err.Error(), ""
}
if framework == devices.VGPUFrameworkNone {
return "vGPU test requires SR-IOV VFs with an mdev or vendor VFIO vGPU framework", ""
}

// Check for available profiles
Expand Down
4 changes: 4 additions & 0 deletions lib/builds/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ func (m *mockInstanceManager) ListInstances(ctx context.Context, filter *instanc
return result, nil
}

func (m *mockInstanceManager) ListInstancesForReconcile(ctx context.Context) ([]instances.Instance, error) {
return m.ListInstances(ctx, nil)
}

func (m *mockInstanceManager) ListSnapshots(ctx context.Context, filter *instances.ListSnapshotsFilter) ([]instances.Snapshot, error) {
return nil, nil
}
Expand Down
Loading
Loading