Skip to content

Commit 79eef07

Browse files
committed
Guard vGPU releases with live-instance claims and branch the integration test by framework
A vGPU assignment goes stale when its release succeeds but the metadata save does not (or start fails between the release and its first save). The in-process owner map only covers assignments created since the last restart and the VFIO handle scan only covers VMs that have opened the device, so after a restart a stale release could still write 0 to a VF during another live instance's pre-open boot window. Consult live instance metadata on every release: when another instance with a live hypervisor process claims the same device path, drop the stale metadata without touching the device. The integration test asserted mdev specifics (UUID, /sys/bus/mdev path) and failed before exercising the lifecycle on a vendor VFIO host. Branch the assertions by framework and extend the test to cover release on stop and reacquisition on start. Also report an absent-but-possibly-valid profile as ambiguous instead of 'not found': the vendor VFIO creatable catalog is capacity-dependent, so a valid larger profile disappears while smaller ones remain.
1 parent c36e950 commit 79eef07

9 files changed

Lines changed: 198 additions & 29 deletions

File tree

integration/vgpu_test.go

Lines changed: 86 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"bytes"
55
"context"
66
"os"
7+
"path/filepath"
8+
"strings"
79
"testing"
810
"time"
911

@@ -21,19 +23,21 @@ import (
2123
"github.com/stretchr/testify/require"
2224
)
2325

24-
// TestVGPU is an integration test that verifies vGPU (SR-IOV mdev) support works.
26+
// TestVGPU is an integration test that verifies vGPU (SR-IOV) support works
27+
// on the host's framework: mdev or NVIDIA's vendor-specific VFIO.
2528
//
2629
// This test automatically detects vGPU availability and skips if:
27-
// - No SR-IOV VFs are found in /sys/class/mdev_bus/
30+
// - No vGPU framework (mdev or vendor VFIO) is discovered
2831
// - No vGPU profiles are available
29-
// - Not running as root (required for mdev creation)
32+
// - Not running as root (required for sysfs vGPU assignment)
3033
// - KVM is not available
3134
//
3235
// To run manually:
3336
//
3437
// sudo go test -v -run TestVGPU -timeout 5m ./integration/...
3538
//
36-
// Note: This test verifies mdev creation and PCI device visibility inside the VM.
39+
// Note: This test verifies vGPU assignment, release on stop, reacquisition on
40+
// start, and PCI device visibility inside the VM.
3741
// It does NOT test nvidia-smi or CUDA functionality since that requires NVIDIA
3842
// guest drivers pre-installed in the image.
3943
func TestVGPU(t *testing.T) {
@@ -159,9 +163,18 @@ func TestVGPU(t *testing.T) {
159163
instanceID = inst.Id
160164
t.Logf("Instance created: %s", inst.Id)
161165

162-
// Verify mdev UUID was assigned
163-
require.NotEmpty(t, inst.GPUMdevUUID, "Instance should have mdev UUID assigned")
164-
t.Logf("mdev UUID: %s", inst.GPUMdevUUID)
166+
// Verify the assignment matches the host's framework
167+
require.NotEmpty(t, inst.GPUDevicePath, "Instance should have a vGPU device path assigned")
168+
switch inst.GPUFramework {
169+
case devices.VGPUFrameworkMdev:
170+
require.NotEmpty(t, inst.GPUMdevUUID, "mdev instance should have a UUID assigned")
171+
t.Logf("mdev UUID: %s", inst.GPUMdevUUID)
172+
case devices.VGPUFrameworkVendorVFIO:
173+
require.Empty(t, inst.GPUMdevUUID, "vendor VFIO instance should not have an mdev UUID")
174+
t.Logf("vendor VFIO VF: %s", inst.GPUDevicePath)
175+
default:
176+
t.Fatalf("unexpected vGPU framework %q", inst.GPUFramework)
177+
}
165178

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

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

191201
// Step 7: Wait for guest agent to be ready
@@ -225,13 +235,74 @@ func TestVGPU(t *testing.T) {
225235
require.NoError(t, err)
226236

227237
assert.Equal(t, profile, actualInst.GPUProfile, "GPU profile should match")
228-
assert.NotEmpty(t, actualInst.GPUMdevUUID, "mdev UUID should be set")
229-
t.Logf("Instance GPU: profile=%s, mdev=%s", actualInst.GPUProfile, actualInst.GPUMdevUUID)
238+
assert.Equal(t, inst.GPUFramework, actualInst.GPUFramework, "framework should match")
239+
assert.NotEmpty(t, actualInst.GPUDevicePath, "device path should be set")
240+
if inst.GPUFramework == devices.VGPUFrameworkMdev {
241+
assert.NotEmpty(t, actualInst.GPUMdevUUID, "mdev UUID should be set")
242+
}
243+
t.Logf("Instance GPU: profile=%s, framework=%s, device=%s", actualInst.GPUProfile, actualInst.GPUFramework, actualInst.GPUDevicePath)
244+
})
245+
246+
// Step 10: Stop releases the assignment
247+
t.Log("Step 10: Stopping instance to release the vGPU...")
248+
_, err = instanceManager.StopInstance(ctx, inst.Id)
249+
require.NoError(t, err, "stop should succeed")
250+
251+
t.Run("VGPUReleasedOnStop", func(t *testing.T) {
252+
stopped, err := instanceManager.GetInstance(ctx, inst.Id)
253+
require.NoError(t, err)
254+
assert.Empty(t, stopped.GPUDevicePath, "assignment metadata should be cleared on stop")
255+
assertVGPUReleased(t, inst.GPUFramework, inst.GPUDevicePath)
256+
})
257+
258+
// Step 11: Start reacquires an assignment
259+
t.Log("Step 11: Starting instance to reacquire a vGPU...")
260+
started, err := instanceManager.StartInstance(ctx, inst.Id, instances.StartInstanceRequest{})
261+
require.NoError(t, err, "start should succeed")
262+
263+
t.Run("VGPUReacquiredOnStart", func(t *testing.T) {
264+
require.NotEmpty(t, started.GPUDevicePath, "start should assign a vGPU")
265+
assert.Equal(t, inst.GPUFramework, started.GPUFramework, "framework should match")
266+
assertVGPUAssigned(t, started.GPUFramework, started.GPUDevicePath)
230267
})
231268

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

272+
// assertVGPUAssigned verifies in sysfs that the device at path carries a live
273+
// vGPU assignment for the given framework.
274+
func assertVGPUAssigned(t *testing.T, framework devices.VGPUFramework, devicePath string) {
275+
t.Helper()
276+
switch framework {
277+
case devices.VGPUFrameworkMdev:
278+
_, err := os.Stat(devicePath)
279+
assert.NoError(t, err, "mdev device should exist at %s", devicePath)
280+
case devices.VGPUFrameworkVendorVFIO:
281+
data, err := os.ReadFile(filepath.Join(devicePath, "nvidia", "current_vgpu_type"))
282+
require.NoError(t, err, "VF should expose current_vgpu_type")
283+
assert.NotEqual(t, "0", strings.TrimSpace(string(data)), "VF should have a vGPU type assigned")
284+
default:
285+
t.Fatalf("unexpected vGPU framework %q", framework)
286+
}
287+
}
288+
289+
// assertVGPUReleased verifies in sysfs that the device at path no longer
290+
// carries a vGPU assignment.
291+
func assertVGPUReleased(t *testing.T, framework devices.VGPUFramework, devicePath string) {
292+
t.Helper()
293+
switch framework {
294+
case devices.VGPUFrameworkMdev:
295+
_, err := os.Stat(devicePath)
296+
assert.True(t, os.IsNotExist(err), "mdev device should be gone from %s", devicePath)
297+
case devices.VGPUFrameworkVendorVFIO:
298+
data, err := os.ReadFile(filepath.Join(devicePath, "nvidia", "current_vgpu_type"))
299+
require.NoError(t, err, "VF should expose current_vgpu_type")
300+
assert.Equal(t, "0", strings.TrimSpace(string(data)), "VF assignment should be released")
301+
default:
302+
t.Fatalf("unexpected vGPU framework %q", framework)
303+
}
304+
}
305+
235306
// checkVGPUTestPrerequisites checks if vGPU test can run.
236307
// Returns (skipReason, profileName) - skipReason is empty if all prerequisites are met.
237308
func checkVGPUTestPrerequisites() (string, string) {

lib/devices/vendor_vfio_linux.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,10 @@ func (s vendorVFIOSysfs) create(ctx context.Context, profileName, instanceID str
141141
if len(metadata) == 0 && len(vfs) > 0 {
142142
return nil, fmt.Errorf("no creatable vGPU profiles on any VF, GPUs may be at capacity: profile %q", profileName)
143143
}
144-
return nil, fmt.Errorf("profile %q not found", profileName)
144+
// The creatable catalog is capacity-dependent: a valid larger profile
145+
// disappears once no GPU can fit it while smaller ones remain, so an
146+
// absent profile is indistinguishable from an unknown one.
147+
return nil, fmt.Errorf("profile %q is not creatable on any VF (unknown profile or insufficient capacity)", profileName)
145148
}
146149

147150
targetVF, err := s.selectLeastLoadedVF(vfs, metadata, requested.TypeName)

lib/devices/vendor_vfio_linux_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,21 @@ func TestVendorVFIOCreateReportsCapacityWhenAllGPUsFull(t *testing.T) {
111111
assert.ErrorContains(t, err, "GPUs may be at capacity")
112112
}
113113

114+
// A partially loaded host drops larger profiles from the creatable lists
115+
// while smaller ones remain, so a missing profile cannot be proven invalid.
116+
func TestVendorVFIOCreateReportsAmbiguousMissingProfile(t *testing.T) {
117+
t.Parallel()
118+
119+
sysfs := newTestVendorVFIOSysfs(t)
120+
sysfs.addVF(t, "0000:82:00.0", "0000:82:00.4", "42", "1148", "")
121+
sysfs.addVF(t, "0000:82:00.0", "0000:82:00.5", "43", "0", "ID : vGPU Name\n1148 : NVIDIA L40S-2Q\n")
122+
123+
_, err := sysfs.create(context.Background(), "NVIDIA L40S-48Q", "instance-1")
124+
require.Error(t, err)
125+
assert.ErrorContains(t, err, "not creatable on any VF")
126+
assert.ErrorContains(t, err, "unknown profile or insufficient capacity")
127+
}
128+
114129
func TestVendorVFIOSelectsLeastLoadedGPU(t *testing.T) {
115130
t.Parallel()
116131

lib/instances/delete.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ func (m *manager) deleteInstanceWithOptions(
129129
// or volume teardown. A failed release retains the instance metadata, and
130130
// nothing destructive has happened to its attachments yet, so a retried
131131
// delete is safe.
132-
if err := releaseStoredVGPU(ctx, stored); err != nil {
132+
if err := m.releaseStoredVGPU(ctx, stored); err != nil {
133133
log.ErrorContext(ctx, "failed to destroy vGPU; retaining instance metadata", "instance_id", id, "error", err)
134134
return fmt.Errorf("destroy vGPU: %w", err)
135135
}

lib/instances/lifecycle_noop_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,46 @@ func TestDeleteRetainsMetadataWhenVGPUReleaseFails(t *testing.T) {
166166
assert.Equal(t, "/sys/bus/pci/devices/0000:82:00.4", stored.GPUDevicePath)
167167
}
168168

169+
// A stale assignment (release succeeded but the save did not) can reference a
170+
// device that has since been reassigned. Releasing it must not touch the
171+
// device out from under the live instance that owns it now; the stale
172+
// metadata is dropped instead, so the delete completes.
173+
func TestDeleteDropsStaleVGPUClaimedByLiveInstance(t *testing.T) {
174+
now := time.Now().UTC()
175+
m, id := newLifecycleNoopManagerWithInstance(t, StateStopped, now)
176+
meta, err := m.loadMetadata(id)
177+
require.NoError(t, err)
178+
meta.GPUProfile = "NVIDIA L40S-2Q"
179+
meta.GPUFramework = devices.VGPUFramework("future-framework")
180+
meta.GPUDevicePath = "/sys/bus/pci/devices/0000:82:00.4"
181+
require.NoError(t, m.saveMetadata(meta))
182+
183+
claimantID := "inst-live-claimant"
184+
require.NoError(t, m.ensureDirectories(claimantID))
185+
pid := os.Getpid()
186+
require.NoError(t, m.saveMetadata(&metadata{StoredMetadata: StoredMetadata{
187+
Id: claimantID,
188+
Name: claimantID,
189+
Image: "test-image",
190+
CreatedAt: now,
191+
HypervisorType: lifecycleNoopHypervisorType,
192+
HypervisorPID: &pid,
193+
SocketPath: m.paths.InstanceSocket(claimantID, "noop.sock"),
194+
DataDir: m.paths.InstanceDir(claimantID),
195+
GPUProfile: "NVIDIA L40S-2Q",
196+
GPUFramework: devices.VGPUFramework("future-framework"),
197+
GPUDevicePath: "/sys/bus/pci/devices/0000:82:00.4",
198+
}}))
199+
200+
require.NoError(t, m.DeleteInstance(context.Background(), id))
201+
202+
_, err = m.loadMetadata(id)
203+
require.Error(t, err, "deleted instance metadata should be gone")
204+
claimant, err := m.loadMetadata(claimantID)
205+
require.NoError(t, err)
206+
assert.Equal(t, "/sys/bus/pci/devices/0000:82:00.4", claimant.GPUDevicePath, "live claimant keeps its assignment")
207+
}
208+
169209
func TestDeleteReleasesVGPUBeforeTeardown(t *testing.T) {
170210
m, id := newLifecycleNoopManagerWithInstance(t, StateStopped, time.Now().UTC())
171211
deviceManager := &recordingDeviceManager{}

lib/instances/start.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ func (m *manager) startInstance(
5353
// cannot leave on-disk metadata pointing at a device that is already
5454
// gone (matching releaseRetainedVGPULocked).
5555
if storedVGPUDevicePath(stored) != "" {
56-
if err := releaseStoredVGPU(ctx, stored); err != nil {
56+
if err := m.releaseStoredVGPU(ctx, stored); err != nil {
5757
log.ErrorContext(ctx, "failed to release stale vGPU before start", "instance_id", id, "error", err)
5858
return nil, fmt.Errorf("release stale vGPU before start: %w", err)
5959
}

lib/instances/stop.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ func (m *manager) stopInstance(
263263
}
264264

265265
// 7. Release the vGPU assignment if present.
266-
if err := releaseStoredVGPU(ctx, stored); err != nil {
266+
if err := m.releaseStoredVGPU(ctx, stored); err != nil {
267267
log.WarnContext(ctx, "failed to destroy vGPU on stop; retaining assignment metadata", "instance_id", id, "error", err)
268268
}
269269

lib/instances/vgpu.go

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

33
import (
44
"context"
5+
"fmt"
56
"path/filepath"
7+
"syscall"
68

79
"github.com/kernel/hypeman/lib/devices"
810
"github.com/kernel/hypeman/lib/logger"
@@ -20,23 +22,59 @@ func clearStoredVGPUDevice(stored *StoredMetadata) {
2022
stored.GPUMdevUUID = ""
2123
}
2224

23-
func releaseStoredVGPU(ctx context.Context, stored *StoredMetadata) error {
25+
func (m *manager) releaseStoredVGPU(ctx context.Context, stored *StoredMetadata) error {
2426
path := storedVGPUDevicePath(stored)
2527
if path != "" {
26-
assignment := devices.VGPUAssignment{
27-
Framework: stored.GPUFramework,
28-
DevicePath: path,
29-
MdevUUID: stored.GPUMdevUUID,
30-
InstanceID: stored.Id,
31-
}
32-
if err := devices.DestroyVGPU(ctx, assignment); err != nil {
28+
claimed, err := m.vgpuAssignmentClaimedByLiveInstance(ctx, stored.Id, path)
29+
if err != nil {
3330
return err
3431
}
32+
if claimed {
33+
// The stored claim is stale: the device now belongs to a live
34+
// instance, so drop the metadata without touching the device.
35+
logger.FromContext(ctx).WarnContext(ctx, "dropping stale vGPU assignment claimed by another live instance",
36+
"instance_id", stored.Id, "device_path", path)
37+
} else {
38+
assignment := devices.VGPUAssignment{
39+
Framework: stored.GPUFramework,
40+
DevicePath: path,
41+
MdevUUID: stored.GPUMdevUUID,
42+
InstanceID: stored.Id,
43+
}
44+
if err := devices.DestroyVGPU(ctx, assignment); err != nil {
45+
return err
46+
}
47+
}
3548
}
3649
clearStoredVGPUDevice(stored)
3750
return nil
3851
}
3952

53+
// vgpuAssignmentClaimedByLiveInstance reports whether an instance other than
54+
// excludeID claims devicePath with a live hypervisor process. An assignment
55+
// goes stale when its release succeeds but the metadata save does not (or
56+
// start fails between the release and its first save); the device can then be
57+
// reassigned, and honoring the stale claim would release it out from under
58+
// the live instance. The in-process owner map only covers assignments created
59+
// since the last restart and the VFIO handle scan only covers VMs that have
60+
// opened the device, so live metadata is the durable source consulted here.
61+
func (m *manager) vgpuAssignmentClaimedByLiveInstance(ctx context.Context, excludeID, devicePath string) (bool, error) {
62+
instances, err := m.listInstances(ctx)
63+
if err != nil {
64+
return false, fmt.Errorf("list instances for vGPU release check: %w", err)
65+
}
66+
for i := range instances {
67+
inst := &instances[i]
68+
if inst.Id == excludeID || inst.GPUDevicePath != devicePath || inst.HypervisorPID == nil {
69+
continue
70+
}
71+
if syscall.Kill(*inst.HypervisorPID, 0) == nil {
72+
return true, nil
73+
}
74+
}
75+
return false, nil
76+
}
77+
4078
// releaseRetainedVGPULocked releases a vGPU assignment retained on a stopped
4179
// instance after a failed release during the original stop. It is a no-op
4280
// when no assignment is retained, and a failed retry only logs so the
@@ -52,7 +90,7 @@ func (m *manager) releaseRetainedVGPULocked(ctx context.Context, id string) {
5290
if storedVGPUDevicePath(stored) == "" {
5391
return
5492
}
55-
if err := releaseStoredVGPU(ctx, stored); err != nil {
93+
if err := m.releaseStoredVGPU(ctx, stored); err != nil {
5694
log.WarnContext(ctx, "failed to destroy retained vGPU; retaining assignment metadata", "instance_id", id, "error", err)
5795
return
5896
}

lib/instances/vgpu_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"testing"
66

77
"github.com/kernel/hypeman/lib/devices"
8+
"github.com/kernel/hypeman/lib/paths"
89
"github.com/stretchr/testify/assert"
910
)
1011

@@ -24,11 +25,12 @@ func TestStoredVGPUDevicePath(t *testing.T) {
2425
func TestReleaseStoredVGPURetainsMetadataOnFailure(t *testing.T) {
2526
t.Parallel()
2627

28+
m := &manager{paths: paths.New(t.TempDir())}
2729
stored := &StoredMetadata{
2830
GPUFramework: devices.VGPUFramework("future-framework"),
2931
GPUDevicePath: "/sys/bus/pci/devices/0000:82:00.4",
3032
}
31-
err := releaseStoredVGPU(context.Background(), stored)
33+
err := m.releaseStoredVGPU(context.Background(), stored)
3234
assert.Error(t, err)
3335
assert.Equal(t, devices.VGPUFramework("future-framework"), stored.GPUFramework)
3436
assert.Equal(t, "/sys/bus/pci/devices/0000:82:00.4", stored.GPUDevicePath)

0 commit comments

Comments
 (0)