Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
81c83f1
Guard vGPU releases with live-instance claims
yummybomb Aug 6, 2026
f61e10b
Reconcile vendor VFIO vGPUs against a fail-closed instance inventory
yummybomb Aug 6, 2026
8a82ea4
Fail closed on vGPU claim checks
yummybomb Aug 6, 2026
0624c4d
Retain only vGPU assignment after failed create
yummybomb Aug 6, 2026
7f92c80
Clear released vGPU assignment on start rollback
yummybomb Aug 6, 2026
cccba0c
Test start rollback vGPU cleanup
yummybomb Aug 6, 2026
88e48ad
Normalize legacy mdev paths in live-claim check
yummybomb Aug 7, 2026
56304a1
Bind the live-claimant test socket under /tmp for macOS
yummybomb Aug 7, 2026
e4edae0
Surface retained vGPU cleanup through a typed create error and manage…
yummybomb Aug 7, 2026
112f2d1
Generalize the create vGPU error text
yummybomb Aug 7, 2026
1e37e1e
Scope vGPU claim scan to vendor VFIO and close reconcile gaps
yummybomb Aug 8, 2026
b97d680
Harden the vendor VFIO release path
yummybomb Aug 9, 2026
7961b45
Fail closed on retained vGPU cleanup
yummybomb Aug 9, 2026
f9794d0
Report surviving vGPU retention metadata
yummybomb Aug 9, 2026
ec52366
Return accurate vGPU cleanup guidance
yummybomb Aug 9, 2026
a83a815
Clarify vGPU retention fallback
yummybomb Aug 9, 2026
3686a68
Pass hypervisor identity token to vGPU claim check
yummybomb Aug 10, 2026
028fd4f
Fail safely on ambiguous vGPU claims
yummybomb Aug 10, 2026
9db1ed6
Expose retained vGPU instance IDs
yummybomb Aug 10, 2026
2e5dc05
Harden vGPU startup rollback recovery
yummybomb Aug 10, 2026
9a9130c
Preserve the create failure cause in vGPU cleanup errors
yummybomb Aug 10, 2026
7a29c9a
Fix vGPU reconciliation edge cases
yummybomb Aug 10, 2026
63ad887
Use boot-scoped hypervisor identities for vGPUs
yummybomb Aug 10, 2026
641b3b0
Run vGPU rollback tests with QEMU
yummybomb Aug 10, 2026
b34441e
Protect new vGPU assignments from stale PIDs
yummybomb Aug 10, 2026
250b570
Persist vGPU assignments after create rollback failure
yummybomb Aug 10, 2026
2138ad3
Preserve vGPU lifecycle compatibility
yummybomb Aug 10, 2026
e6113fd
Reconcile vGPU protection from raw metadata and restore GPUAssignedAt
yummybomb Aug 10, 2026
4fc471a
Surface pending vGPU cleanup from start as a typed error
yummybomb Aug 11, 2026
05e9263
Cover retained-stub delete recovery and flag reconcile inventory fail…
yummybomb Aug 11, 2026
7280b74
Reject vendor VFIO vGPUs on Cloud Hypervisor and improve wedge forensics
yummybomb Aug 11, 2026
a54afba
Surface retained rollback assignments from start as vgpu_cleanup_pending
yummybomb Aug 11, 2026
e54df7a
Report retention as persisted when the mid-start save survives
yummybomb Aug 11, 2026
d5c020d
Leave vGPU hypervisor selection to callers
yummybomb Aug 11, 2026
dca142e
Carry identity fields into the create-pending retention stub
yummybomb Aug 11, 2026
093de5c
Grace recent dead-PID claims in the release scan like reconcile does
yummybomb Aug 11, 2026
e5878b8
Reject start on vGPU retention records
yummybomb Aug 13, 2026
9f5e8d3
Make vGPU retention records fully delete-only
yummybomb Aug 13, 2026
dfcfeb9
Adapt vGPU liveness guards to the identity struct resolver
yummybomb Aug 14, 2026
2a5ca9f
Deep-copy the phase tracker into the start rollback snapshot
yummybomb Aug 17, 2026
8c1c453
Retry orphaned vGPU releases in the background after delete
yummybomb Aug 18, 2026
44f75c1
Bound orphan retries in delete tests and tighten comments
yummybomb Aug 18, 2026
5feb197
SIGTERM vGPU QEMU before SIGKILL during driver init
yummybomb Aug 19, 2026
20d7bb6
Apply vGPU SIGTERM grace on stop's direct kill paths
yummybomb Aug 19, 2026
4fcc51d
Lower vGPU SIGTERM grace to 5s
yummybomb Aug 19, 2026
f97a96f
Apply vGPU SIGTERM grace in every instance state
yummybomb Aug 20, 2026
c64f62c
Harden the vGPU claim scan and retention-stub stop path
yummybomb Aug 20, 2026
1abad65
Share one vGPU retention stub in create
yummybomb Aug 21, 2026
4a315df
Name the strict metadata listing variant
yummybomb Aug 21, 2026
27fdda2
Skip instances deleted mid-listing in reconcile protection
yummybomb Aug 21, 2026
704bf03
Count abandoned orphaned vGPU releases
yummybomb Aug 21, 2026
36e5289
Define the vGPU retention-stub rejection once
yummybomb Aug 21, 2026
30ce96f
Render pending vGPU cleanup responses through one helper
yummybomb Aug 21, 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
41 changes: 41 additions & 0 deletions cmd/api/api/instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,18 @@ 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 pending vGPU cleanup.
case errors.As(err, &vgpuPending):
log.ErrorContext(ctx, "failed to create instance", "error", err, "image", request.Body.Image)
message, inner := vgpuCleanupPendingDetail(vgpuPending, "create", "delete it to retry")
return oapi.CreateInstance500JSONResponse{
Code: "vgpu_cleanup_pending",
Message: message,
InnerError: inner,
}, nil
Comment thread
cursor[bot] marked this conversation as resolved.
case errors.Is(err, instances.ErrImageNotReady):
return oapi.CreateInstance400JSONResponse{
Code: "image_not_ready",
Expand Down Expand Up @@ -424,6 +435,22 @@ func (s *ApiService) CreateInstance(ctx context.Context, request oapi.CreateInst
return oapi.CreateInstance201JSONResponse(instanceToOAPI(*inst)), nil
}

// vgpuCleanupPendingDetail renders a pending vGPU cleanup into the message
// and inner error detail shared by the create and start handlers. The
// retained guidance names the verb-specific way to release the assignment.
func vgpuCleanupPendingDetail(pending *instances.VGPUCleanupPendingError, action, retainedGuidance string) (string, *oapi.ErrorDetail) {
message := fmt.Sprintf("failed to %s instance: %v; vGPU release failed during rollback and instance %s retains the assignment, %s", action, pending.Err, pending.InstanceID, retainedGuidance)
innerCode := "vgpu_retained_instance"
if !pending.Retained {
message = fmt.Sprintf("failed to %s instance: %v; vGPU release failed during rollback and the retention record for instance %s could not be saved; the assignment is recovered on the next startup reconcile", action, pending.Err, pending.InstanceID)
innerCode = "vgpu_unretained_instance"
}
return message, &oapi.ErrorDetail{
Code: lo.ToPtr(innerCode),
Message: lo.ToPtr(pending.InstanceID),
}
}

// GetInstance gets instance details
// The id parameter can be an instance ID, name, or ID prefix
// Note: Resolution is handled by ResolveResource middleware
Expand Down Expand Up @@ -823,7 +850,18 @@ func (s *ApiService) StartInstance(ctx context.Context, request oapi.StartInstan

result, err := s.InstanceManager.StartInstance(ctx, inst.Id, startReq)
if err != nil {
var vgpuPending *instances.VGPUCleanupPendingError
switch {
// Checked first: it wraps the original start error, so a later
// errors.Is case would match the cause and hide the pending vGPU cleanup.
case errors.As(err, &vgpuPending):
log.ErrorContext(ctx, "failed to start instance", "error", err)
message, inner := vgpuCleanupPendingDetail(vgpuPending, "start", "delete it or retry start to release it")
return oapi.StartInstance500JSONResponse{
Code: "vgpu_cleanup_pending",
Message: message,
InnerError: inner,
}, nil
case errors.Is(err, instances.ErrInvalidState):
return oapi.StartInstance409JSONResponse{
Code: "invalid_state",
Expand Down Expand Up @@ -1258,6 +1296,9 @@ func instanceToOAPI(inst instances.Instance) oapi.Instance {
if inst.GPUMdevUUID != "" {
gpu.MdevUuid = lo.ToPtr(inst.GPUMdevUUID)
}
if inst.GPUDevicePath != "" {
gpu.DevicePath = lo.ToPtr(inst.GPUDevicePath)
}
oapiInst.Gpu = gpu
}

Expand Down
130 changes: 130 additions & 0 deletions cmd/api/api/instances_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,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 @@ -47,6 +48,73 @@ 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",
Retained: true,
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")
assert.Contains(t, pending.Message, network.ErrNameExists.Error(),
"the underlying create failure must survive the cleanup guidance")
assert.Contains(t, pending.Message, "delete it to retry")
require.NotNil(t, pending.InnerError)
require.NotNil(t, pending.InnerError.Code)
assert.Equal(t, "vgpu_retained_instance", *pending.InnerError.Code)
require.NotNil(t, pending.InnerError.Message)
assert.Equal(t, "inst-1", *pending.InnerError.Message)
}

func TestCreateInstance_VGPUCleanupPendingWithoutRetentionUsesReconcileGuidance(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, "retention record for instance inst-1 could not be saved")
assert.Contains(t, pending.Message, network.ErrNameExists.Error(),
"the underlying create failure must survive the cleanup guidance")
assert.Contains(t, pending.Message, "startup reconcile")
assert.NotContains(t, pending.Message, "delete")
require.NotNil(t, pending.InnerError)
require.NotNil(t, pending.InnerError.Code)
assert.Equal(t, "vgpu_unretained_instance", *pending.InnerError.Code)
require.NotNil(t, pending.InnerError.Message)
assert.Equal(t, "inst-1", *pending.InnerError.Message)
}

func TestCreateInstance_AutoPullImage(t *testing.T) {
t.Parallel()
if _, err := os.Stat("/dev/kvm"); os.IsNotExist(err) {
Expand Down Expand Up @@ -923,6 +991,68 @@ func (m *errActionInstanceManager) RestoreSnapshot(context.Context, string, stri
return nil, m.err
}

// A retained-assignment error must win over the mapping of the start error
// it wraps, or the response omits the pending vGPU cleanup the caller has to
// resolve.
func TestStartInstance_VGPUCleanupPendingBeatsWrappedErrorMapping(t *testing.T) {
t.Parallel()

resolved := &instances.Instance{
StoredMetadata: instances.StoredMetadata{Id: "inst-1", Name: "inst-1"},
State: instances.StateStopped,
}

t.Run("retained", func(t *testing.T) {
t.Parallel()
svc := newTestService(t)
svc.InstanceManager = &errActionInstanceManager{Manager: svc.InstanceManager, err: &instances.VGPUCleanupPendingError{
InstanceID: "inst-1",
Retained: true,
Err: fmt.Errorf("create vGPU for profile p: %w", instances.ErrInsufficientResources),
}}

resp, rerr := svc.StartInstance(mw.WithResolvedInstance(ctx(), resolved.Id, resolved), oapi.StartInstanceRequestObject{Id: resolved.Id})
require.NoError(t, rerr)

pending, ok := resp.(oapi.StartInstance500JSONResponse)
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")
assert.Contains(t, pending.Message, instances.ErrInsufficientResources.Error(),
"the underlying start failure must survive the cleanup guidance")
assert.Contains(t, pending.Message, "delete it or retry start")
require.NotNil(t, pending.InnerError)
require.NotNil(t, pending.InnerError.Code)
assert.Equal(t, "vgpu_retained_instance", *pending.InnerError.Code)
require.NotNil(t, pending.InnerError.Message)
assert.Equal(t, "inst-1", *pending.InnerError.Message)
})

t.Run("unretained", func(t *testing.T) {
t.Parallel()
svc := newTestService(t)
svc.InstanceManager = &errActionInstanceManager{Manager: svc.InstanceManager, err: &instances.VGPUCleanupPendingError{
InstanceID: "inst-1",
Err: fmt.Errorf("create vGPU for profile p: %w", instances.ErrInsufficientResources),
}}

resp, rerr := svc.StartInstance(mw.WithResolvedInstance(ctx(), resolved.Id, resolved), oapi.StartInstanceRequestObject{Id: resolved.Id})
require.NoError(t, rerr)

pending, ok := resp.(oapi.StartInstance500JSONResponse)
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, "retention record for instance inst-1 could not be saved")
assert.Contains(t, pending.Message, "startup reconcile")
assert.NotContains(t, pending.Message, "delete")
require.NotNil(t, pending.InnerError)
require.NotNil(t, pending.InnerError.Code)
assert.Equal(t, "vgpu_unretained_instance", *pending.InnerError.Code)
require.NotNil(t, pending.InnerError.Message)
assert.Equal(t, "inst-1", *pending.InnerError.Message)
})
}

func TestInstanceActions_ImageNotFoundMapsTo404(t *testing.T) {
t.Parallel()

Expand Down
66 changes: 60 additions & 6 deletions cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,63 @@ func configureUFFDGraduationController(cfg *config.Config, instanceManager insta
}, logger), nil
}

func liveInstanceVGPUDevicePaths(ctx context.Context, instanceManager instances.Manager) (map[string]struct{}, time.Duration, error) {
allInstances, err := instanceManager.ListInstancesForReconcile(ctx)
if err != nil {
return nil, 0, err
}
protected := make(map[string]struct{})
var retryAfter time.Duration
for _, inst := range allInstances {
if inst.GPUDevicePath == "" {
continue
}
if inst.HypervisorPID != nil && instances.HypervisorMayBeAlive(inst.HypervisorProcessIdentity, inst.SocketPath) {
protected[inst.GPUDevicePath] = struct{}{}
continue
Comment thread
cursor[bot] marked this conversation as resolved.
}
if inst.GPUAssignedAt == nil {
continue
}
remaining := instances.VGPUAssignmentStartupGracePeriod - time.Since(*inst.GPUAssignedAt)
if remaining <= 0 {
continue
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
}
protected[inst.GPUDevicePath] = struct{}{}
if retryAfter == 0 || remaining < retryAfter {
retryAfter = remaining
}
}
return protected, retryAfter, nil
}
Comment thread
cursor[bot] marked this conversation as resolved.

func reconcileVGPUs(ctx context.Context, instanceManager instances.Manager, logger *slog.Logger) {
protected, retryAfter, err := liveInstanceVGPUDevicePaths(ctx, instanceManager)
if err != nil {
// Operator-actionable: vendor VFIO reconciliation stays disabled
// host-wide (and releases fail closed on the same inventory) until
// the unreadable instance metadata is repaired.
logger.Error("failed to list instances for vGPU reconcile protection; reconciling mdev only", "error", err)
protected = nil
retryAfter = 0
}
Comment thread
cursor[bot] marked this conversation as resolved.
if err := devices.ReconcileVGPUs(ctx, protected); err != nil {
logger.Warn("failed to reconcile vGPU devices", "error", err)
}
if retryAfter <= 0 {
return
}
go func() {
timer := time.NewTimer(retryAfter)
defer timer.Stop()
select {
case <-ctx.Done():
case <-timer.C:
reconcileVGPUs(ctx, instanceManager, logger)
}
}()
}

func run() error {
startupStarted := time.Now()
slog.Info("starting hypeman initialization")
Expand Down Expand Up @@ -384,12 +441,9 @@ 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...")
reconcileVGPUs(ctx, app.InstanceManager, logger)

// Wire up resource validator for aggregate limit checking
// This enables the instance manager to validate CPU, memory, network, and GPU
Expand Down
38 changes: 38 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,38 @@ 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
}

func TestLiveInstanceVGPUDevicePathsBoundsStartupProtection(t *testing.T) {
dead := exec.Command("true")
require.NoError(t, dead.Run())
deadPID := dead.Process.Pid
recent := time.Now().Add(-time.Minute)
stale := time.Now().Add(-instances.VGPUAssignmentStartupGracePeriod - time.Minute)

manager := vgpuReconcileManagerStub{list: []instances.Instance{
{StoredMetadata: instances.StoredMetadata{Id: "booting", GPUDevicePath: "/sys/bus/pci/devices/0000:82:00.4", GPUAssignedAt: &recent}},
{StoredMetadata: instances.StoredMetadata{Id: "orphaned", GPUDevicePath: "/sys/bus/pci/devices/0000:82:00.5", GPUAssignedAt: &stale}},
{StoredMetadata: instances.StoredMetadata{Id: "legacy", GPUDevicePath: "/sys/bus/pci/devices/0000:82:00.6"}},
{StoredMetadata: instances.StoredMetadata{Id: "dead", GPUDevicePath: "/sys/bus/pci/devices/0000:82:00.7", HypervisorProcessIdentity: instances.HypervisorProcessIdentity{HypervisorPID: &deadPID}}},
{StoredMetadata: instances.StoredMetadata{Id: "stale-pid-booting", GPUDevicePath: "/sys/bus/pci/devices/0000:82:00.8", HypervisorProcessIdentity: instances.HypervisorProcessIdentity{HypervisorPID: &deadPID}, GPUAssignedAt: &recent}},
}}

protected, retryAfter, err := liveInstanceVGPUDevicePaths(context.Background(), manager)
require.NoError(t, err)
require.Positive(t, retryAfter)
require.LessOrEqual(t, retryAfter, instances.VGPUAssignmentStartupGracePeriod)
assert.Contains(t, protected, "/sys/bus/pci/devices/0000:82:00.4")
assert.NotContains(t, protected, "/sys/bus/pci/devices/0000:82:00.5")
assert.NotContains(t, protected, "/sys/bus/pci/devices/0000:82:00.6")
assert.NotContains(t, protected, "/sys/bus/pci/devices/0000:82:00.7")
assert.Contains(t, protected, "/sys/bus/pci/devices/0000:82:00.8")
}
5 changes: 0 additions & 5 deletions integration/vgpu_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -324,11 +324,6 @@ func checkVGPUTestPrerequisites() (string, string) {
if framework == devices.VGPUFrameworkNone {
return "vGPU test requires SR-IOV VFs with an mdev or vendor VFIO vGPU framework", ""
}
if framework == devices.VGPUFrameworkVendorVFIO {
// CreateVGPU rejects vendor VFIO until the instance lifecycle
// integration lands.
return "vGPU test requires the vendor VFIO instance lifecycle integration", ""
}

// Check for available profiles
profiles, err := devices.ListGPUProfiles()
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