Skip to content

Commit c1c272e

Browse files
committed
Harden vGPU startup rollback recovery
1 parent 295bd43 commit c1c272e

7 files changed

Lines changed: 155 additions & 46 deletions

File tree

cmd/api/main.go

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -172,26 +172,62 @@ func configureUFFDGraduationController(cfg *config.Config, instanceManager insta
172172
}, logger), nil
173173
}
174174

175-
func liveInstanceVGPUDevicePaths(ctx context.Context, instanceManager instances.Manager) (map[string]struct{}, error) {
175+
const vgpuAssignmentStartupGracePeriod = 5 * time.Minute
176+
177+
func liveInstanceVGPUDevicePaths(ctx context.Context, instanceManager instances.Manager) (map[string]struct{}, time.Duration, error) {
176178
allInstances, err := instanceManager.ListInstancesForReconcile(ctx)
177179
if err != nil {
178-
return nil, err
180+
return nil, 0, err
179181
}
180182
protected := make(map[string]struct{})
183+
var retryAfter time.Duration
181184
for _, inst := range allInstances {
182185
if inst.GPUDevicePath == "" {
183186
continue
184187
}
185-
// A nil PID does not mean the assignment is orphaned: the PID is
186-
// persisted only after the hypervisor starts, so a crash during boot
187-
// leaves the device path without one. Only skip protection when the
188-
// recorded hypervisor is known to be gone.
189-
if inst.HypervisorPID != nil && !instances.HypervisorProcessExists(*inst.HypervisorPID, inst.SocketPath) {
188+
if inst.HypervisorPID != nil {
189+
if !instances.HypervisorProcessIdentityExists(*inst.HypervisorPID, inst.HypervisorStartTime, inst.SocketPath) {
190+
continue
191+
}
192+
protected[inst.GPUDevicePath] = struct{}{}
193+
continue
194+
}
195+
if inst.GPUAssignedAt == nil {
196+
continue
197+
}
198+
remaining := vgpuAssignmentStartupGracePeriod - time.Since(*inst.GPUAssignedAt)
199+
if remaining <= 0 {
190200
continue
191201
}
192202
protected[inst.GPUDevicePath] = struct{}{}
203+
if retryAfter == 0 || remaining < retryAfter {
204+
retryAfter = remaining
205+
}
193206
}
194-
return protected, nil
207+
return protected, retryAfter, nil
208+
}
209+
210+
func reconcileVGPUs(ctx context.Context, instanceManager instances.Manager, logger *slog.Logger) {
211+
protected, retryAfter, err := liveInstanceVGPUDevicePaths(ctx, instanceManager)
212+
if err != nil {
213+
logger.Warn("failed to list instances for vGPU reconcile protection; skipping vendor VFIO reconciliation", "error", err)
214+
return
215+
}
216+
if err := devices.ReconcileVGPUs(ctx, protected); err != nil {
217+
logger.Warn("failed to reconcile vGPU devices", "error", err)
218+
}
219+
if retryAfter <= 0 {
220+
return
221+
}
222+
go func() {
223+
timer := time.NewTimer(retryAfter)
224+
defer timer.Stop()
225+
select {
226+
case <-ctx.Done():
227+
case <-timer.C:
228+
reconcileVGPUs(ctx, instanceManager, logger)
229+
}
230+
}()
195231
}
196232

197233
func run() error {
@@ -386,15 +422,7 @@ func run() error {
386422

387423
// Reconcile vGPU devices (clears orphaned vGPUs from previous runs)
388424
logger.Info("Reconciling vGPU devices...")
389-
protected, err := liveInstanceVGPUDevicePaths(app.Ctx, app.InstanceManager)
390-
if err != nil {
391-
logger.Warn("failed to list instances for vGPU reconcile protection; skipping vendor VFIO reconciliation", "error", err)
392-
protected = nil
393-
}
394-
if err := devices.ReconcileVGPUs(app.Ctx, protected); err != nil {
395-
// Log but don't fail - vGPU cleanup is best-effort
396-
logger.Warn("failed to reconcile vGPU devices", "error", err)
397-
}
425+
reconcileVGPUs(ctx, app.InstanceManager, logger)
398426

399427
// Wire up resource validator for aggregate limit checking
400428
// This enables the instance manager to validate CPU, memory, network, and GPU

cmd/api/main_test.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -351,20 +351,26 @@ func (s vgpuReconcileManagerStub) ListInstancesForReconcile(context.Context) ([]
351351
return s.list, nil
352352
}
353353

354-
// The hypervisor PID is persisted only after boot, so an assignment without
355-
// one may belong to a VM that is still starting and must stay protected.
356-
func TestLiveInstanceVGPUDevicePathsProtectsAssignmentsWithoutPID(t *testing.T) {
354+
func TestLiveInstanceVGPUDevicePathsBoundsProtectionWithoutPID(t *testing.T) {
357355
dead := exec.Command("true")
358356
require.NoError(t, dead.Run())
359357
deadPID := dead.Process.Pid
358+
recent := time.Now().Add(-time.Minute)
359+
stale := time.Now().Add(-vgpuAssignmentStartupGracePeriod - time.Minute)
360360

361361
manager := vgpuReconcileManagerStub{list: []instances.Instance{
362-
{StoredMetadata: instances.StoredMetadata{Id: "booting", GPUDevicePath: "/sys/bus/pci/devices/0000:82:00.4"}},
363-
{StoredMetadata: instances.StoredMetadata{Id: "dead", GPUDevicePath: "/sys/bus/pci/devices/0000:82:00.5", HypervisorPID: &deadPID}},
362+
{StoredMetadata: instances.StoredMetadata{Id: "booting", GPUDevicePath: "/sys/bus/pci/devices/0000:82:00.4", GPUAssignedAt: &recent}},
363+
{StoredMetadata: instances.StoredMetadata{Id: "orphaned", GPUDevicePath: "/sys/bus/pci/devices/0000:82:00.5", GPUAssignedAt: &stale}},
364+
{StoredMetadata: instances.StoredMetadata{Id: "legacy", GPUDevicePath: "/sys/bus/pci/devices/0000:82:00.6"}},
365+
{StoredMetadata: instances.StoredMetadata{Id: "dead", GPUDevicePath: "/sys/bus/pci/devices/0000:82:00.7", HypervisorPID: &deadPID}},
364366
}}
365367

366-
protected, err := liveInstanceVGPUDevicePaths(context.Background(), manager)
368+
protected, retryAfter, err := liveInstanceVGPUDevicePaths(context.Background(), manager)
367369
require.NoError(t, err)
370+
require.Positive(t, retryAfter)
371+
require.LessOrEqual(t, retryAfter, vgpuAssignmentStartupGracePeriod)
368372
assert.Contains(t, protected, "/sys/bus/pci/devices/0000:82:00.4")
369373
assert.NotContains(t, protected, "/sys/bus/pci/devices/0000:82:00.5")
374+
assert.NotContains(t, protected, "/sys/bus/pci/devices/0000:82:00.6")
375+
assert.NotContains(t, protected, "/sys/bus/pci/devices/0000:82:00.7")
370376
}

lib/instances/create.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ func (m *manager) createInstance(
265265
var gpuFramework devices.VGPUFramework
266266
var gpuDevicePath string
267267
var gpuMdevUUID string
268+
var gpuAssignedAt *time.Time
268269
var stored *StoredMetadata
269270
var retainedVGPU *StoredMetadata
270271

@@ -306,6 +307,8 @@ func (m *manager) createInstance(
306307
gpuFramework = gpuDevice.Framework
307308
gpuDevicePath = gpuDevice.SysfsPath
308309
gpuMdevUUID = gpuDevice.MdevUUID
310+
assignedAt := m.nowUTC()
311+
gpuAssignedAt = &assignedAt
309312

310313
// Add vGPU cleanup to stack
311314
cu.Add(func() {
@@ -406,6 +409,7 @@ func (m *manager) createInstance(
406409
GPUFramework: gpuFramework,
407410
GPUDevicePath: gpuDevicePath,
408411
GPUMdevUUID: gpuMdevUUID,
412+
GPUAssignedAt: gpuAssignedAt,
409413
Entrypoint: req.Entrypoint,
410414
Cmd: req.Cmd,
411415
SkipKernelHeaders: req.SkipKernelHeaders,

lib/instances/start.go

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"fmt"
66
"time"
77

8-
"github.com/kernel/hypeman/lib/devices"
98
"github.com/kernel/hypeman/lib/egressproxy"
109
"github.com/kernel/hypeman/lib/instances/phasetracking"
1110
"github.com/kernel/hypeman/lib/logger"
@@ -63,6 +62,8 @@ func (m *manager) startInstance(
6362
}
6463
}
6564

65+
rollbackMeta := *meta
66+
6667
// 2a. Clear stale exit info from previous run and apply command overrides
6768
stored.ExitCode = nil
6869
stored.ExitMessage = ""
@@ -167,26 +168,11 @@ func (m *manager) startInstance(
167168
log.ErrorContext(ctx, "failed to create vGPU", "instance_id", id, "profile", stored.GPUProfile, "error", err)
168169
return nil, fmt.Errorf("create vGPU for profile %s: %w", stored.GPUProfile, err)
169170
}
170-
setStoredVGPUDevice(stored, device)
171+
assignedAt := m.nowUTC()
172+
setStoredVGPUDevice(stored, device, assignedAt)
171173
// Add vGPU cleanup to stack
172174
cu.Add(func() {
173-
assignment := devices.VGPUAssignment{
174-
Framework: device.Framework,
175-
DevicePath: device.SysfsPath,
176-
MdevUUID: device.MdevUUID,
177-
InstanceID: id,
178-
}
179-
if err := m.destroyVGPUAssignment(ctx, assignment); err != nil {
180-
log.WarnContext(ctx, "failed to destroy vGPU on cleanup", "instance_id", id, "error", err)
181-
if saveErr := m.saveMetadata(meta); saveErr != nil {
182-
log.ErrorContext(ctx, "failed to retain vGPU assignment metadata after cleanup failure", "instance_id", id, "error", saveErr)
183-
}
184-
} else {
185-
clearStoredVGPUDevice(stored)
186-
if saveErr := m.saveMetadata(meta); saveErr != nil {
187-
log.ErrorContext(ctx, "failed to save metadata after vGPU cleanup", "instance_id", id, "error", saveErr)
188-
}
189-
}
175+
m.cleanupStartVGPU(ctx, id, device, assignedAt, rollbackMeta)
190176
})
191177
if err := m.saveMetadata(meta); err != nil {
192178
log.ErrorContext(ctx, "failed to save metadata after vGPU creation", "instance_id", id, "error", err)

lib/instances/types.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,8 @@ type StoredMetadata struct {
153153
GPUProfile string // vGPU profile name (e.g., "L40S-1Q")
154154
GPUFramework devices.VGPUFramework
155155
GPUDevicePath string
156-
GPUMdevUUID string // populated for mdev-backed vGPUs
156+
GPUMdevUUID string // populated for mdev-backed vGPUs
157+
GPUAssignedAt *time.Time // set before hypervisor startup to bound crash recovery protection
157158

158159
// Command overrides (like docker run <image> <command>)
159160
Entrypoint []string // Override image entrypoint (nil = use image default)

lib/instances/vgpu.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"path/filepath"
7+
"time"
78

89
"github.com/kernel/hypeman/lib/devices"
910
"github.com/kernel/hypeman/lib/logger"
@@ -43,16 +44,40 @@ func (m *manager) destroyVGPUAssignment(ctx context.Context, assignment devices.
4344
return destroy(ctx, assignment)
4445
}
4546

46-
func setStoredVGPUDevice(stored *StoredMetadata, device *devices.VGPUDevice) {
47+
func setStoredVGPUDevice(stored *StoredMetadata, device *devices.VGPUDevice, assignedAt time.Time) {
4748
stored.GPUFramework = device.Framework
4849
stored.GPUDevicePath = device.SysfsPath
4950
stored.GPUMdevUUID = device.MdevUUID
51+
stored.GPUAssignedAt = &assignedAt
5052
}
5153

5254
func clearStoredVGPUDevice(stored *StoredMetadata) {
5355
stored.GPUFramework = devices.VGPUFrameworkNone
5456
stored.GPUDevicePath = ""
5557
stored.GPUMdevUUID = ""
58+
stored.GPUAssignedAt = nil
59+
}
60+
61+
func (m *manager) cleanupStartVGPU(ctx context.Context, instanceID string, device *devices.VGPUDevice, assignedAt time.Time, rollbackMeta metadata) {
62+
assignment := devices.VGPUAssignment{
63+
Framework: device.Framework,
64+
DevicePath: device.SysfsPath,
65+
MdevUUID: device.MdevUUID,
66+
InstanceID: instanceID,
67+
}
68+
cleanupMeta := rollbackMeta
69+
releaseErr := m.destroyVGPUAssignment(ctx, assignment)
70+
if releaseErr != nil {
71+
logger.FromContext(ctx).WarnContext(ctx, "failed to destroy vGPU on cleanup", "instance_id", instanceID, "error", releaseErr)
72+
setStoredVGPUDevice(&cleanupMeta.StoredMetadata, device, assignedAt)
73+
}
74+
if err := m.saveMetadata(&cleanupMeta); err != nil {
75+
message := "failed to save metadata after vGPU cleanup"
76+
if releaseErr != nil {
77+
message = "failed to retain vGPU assignment metadata after cleanup failure"
78+
}
79+
logger.FromContext(ctx).ErrorContext(ctx, message, "instance_id", instanceID, "error", err)
80+
}
5681
}
5782

5883
func (m *manager) releaseStoredVGPU(ctx context.Context, stored *StoredMetadata) error {

lib/instances/vgpu_test.go

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"path/filepath"
88
"sync"
99
"testing"
10+
"time"
1011

1112
"github.com/kernel/hypeman/lib/devices"
1213
"github.com/kernel/hypeman/lib/paths"
@@ -180,13 +181,68 @@ func TestStartRollbackRetainsVGPUAssignmentAfterFailedDestroy(t *testing.T) {
180181
})
181182

182183
t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "missing"))
183-
_, err := m.startInstance(context.Background(), id, StartInstanceRequest{})
184+
_, err := m.startInstance(context.Background(), id, StartInstanceRequest{Entrypoint: []string{"new-entrypoint"}})
184185
require.Error(t, err)
185186

186187
stored, err := m.loadMetadata(id)
187188
require.NoError(t, err)
188189
assert.Equal(t, devices.VGPUFrameworkVendorVFIO, stored.GPUFramework)
189190
assert.Equal(t, "/sys/bus/pci/devices/0000:82:00.4", stored.GPUDevicePath)
191+
assert.NotNil(t, stored.GPUAssignedAt)
192+
assert.Empty(t, stored.Entrypoint)
193+
}
194+
195+
func TestCleanupStartVGPURestoresMetadataAfterBootFailure(t *testing.T) {
196+
m := &manager{
197+
paths: paths.New(t.TempDir()),
198+
destroyVGPU: func(context.Context, devices.VGPUAssignment) error {
199+
return nil
200+
},
201+
}
202+
const id = "failed-start"
203+
require.NoError(t, m.ensureDirectories(id))
204+
205+
previousStart := time.Now().Add(-time.Hour).UTC()
206+
previousProgramStart := previousStart.Add(time.Second)
207+
exitCode := 1
208+
rollbackMeta := metadata{StoredMetadata: StoredMetadata{
209+
Id: id,
210+
GPUProfile: "NVIDIA L40S-2Q",
211+
Entrypoint: []string{"old-entrypoint"},
212+
Cmd: []string{"old-command"},
213+
StartedAt: &previousStart,
214+
ProgramStartedAt: &previousProgramStart,
215+
ExitCode: &exitCode,
216+
ExitMessage: "previous exit",
217+
}}
218+
219+
partial := rollbackMeta
220+
partial.Entrypoint = []string{"new-entrypoint"}
221+
partial.Cmd = []string{"new-command"}
222+
partial.StartedAt = ptr(time.Now().UTC())
223+
partial.ProgramStartedAt = nil
224+
partial.ExitCode = nil
225+
partial.ExitMessage = ""
226+
assignedAt := time.Now().UTC()
227+
device := &devices.VGPUDevice{
228+
Framework: devices.VGPUFrameworkVendorVFIO,
229+
SysfsPath: "/sys/bus/pci/devices/0000:82:00.4",
230+
}
231+
setStoredVGPUDevice(&partial.StoredMetadata, device, assignedAt)
232+
require.NoError(t, m.saveMetadata(&partial))
233+
234+
m.cleanupStartVGPU(context.Background(), id, device, assignedAt, rollbackMeta)
235+
236+
stored, err := m.loadMetadata(id)
237+
require.NoError(t, err)
238+
assert.Equal(t, rollbackMeta.Entrypoint, stored.Entrypoint)
239+
assert.Equal(t, rollbackMeta.Cmd, stored.Cmd)
240+
assert.Equal(t, rollbackMeta.StartedAt, stored.StartedAt)
241+
assert.Equal(t, rollbackMeta.ProgramStartedAt, stored.ProgramStartedAt)
242+
assert.Equal(t, rollbackMeta.ExitCode, stored.ExitCode)
243+
assert.Equal(t, rollbackMeta.ExitMessage, stored.ExitMessage)
244+
assert.Empty(t, stored.GPUDevicePath)
245+
assert.Nil(t, stored.GPUAssignedAt)
190246
}
191247

192248
func TestVGPUAssignmentClaimedByLiveInstanceFailsOnInvalidMetadata(t *testing.T) {
@@ -333,16 +389,19 @@ func TestReleaseStoredVGPURetainsMetadataOnFailure(t *testing.T) {
333389
func TestSetAndClearStoredVGPUDevice(t *testing.T) {
334390
t.Parallel()
335391

392+
assignedAt := time.Now().UTC()
336393
stored := &StoredMetadata{}
337394
setStoredVGPUDevice(stored, &devices.VGPUDevice{
338395
Framework: devices.VGPUFrameworkVendorVFIO,
339396
SysfsPath: "/sys/bus/pci/devices/0000:82:00.4",
340-
})
397+
}, assignedAt)
341398
assert.Equal(t, devices.VGPUFrameworkVendorVFIO, stored.GPUFramework)
342399
assert.Equal(t, "/sys/bus/pci/devices/0000:82:00.4", stored.GPUDevicePath)
400+
assert.Equal(t, assignedAt, *stored.GPUAssignedAt)
343401

344402
clearStoredVGPUDevice(stored)
345403
assert.Empty(t, stored.GPUFramework)
346404
assert.Empty(t, stored.GPUDevicePath)
347405
assert.Empty(t, stored.GPUMdevUUID)
406+
assert.Nil(t, stored.GPUAssignedAt)
348407
}

0 commit comments

Comments
 (0)