Skip to content

Commit f3bf0cc

Browse files
authored
Tidy VF health store boundaries and admission errors
- Name the unavailable VF health store in the GPU admission error instead of reporting slot exhaustion with used < total. - Load the VF health store in devices.NewManager so a manager built outside wire cannot run placement against an empty, non-persisting quarantine set. - Inline the single-caller placement-lock wrapper and the locked checked-addresses variant; AllocatableVFs no longer reaches into store internals. - Deduplicate the sentinel controller's probe block; distinguish the stat-failure and load-failure warn messages. - Restore the fail-closed rationale comment on listMetadataFilesStrict and document the archive-before-assign detection window in GPU.md.
1 parent 0ca236d commit f3bf0cc

10 files changed

Lines changed: 63 additions & 58 deletions

File tree

lib/devices/GPU.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -302,8 +302,12 @@ one gets through.
302302

303303
Detection requires the hypeman guest agent: an image that skips the agent
304304
never reports, so a wedge hit exclusively by such images stays undetected in
305-
v1. The scanner requires the complete standalone guest-agent log envelope, so
306-
customer commands and ordinary output containing the marker do not match. The
305+
v1. Start archives the previous boot's app log before persisting a new
306+
assignment, so a marker written within one scan interval of a stop/start is
307+
archived unscanned; that VF returns to the pool unconvicted until the next
308+
victim boot re-emits the marker. The scanner requires the complete standalone
309+
guest-agent log envelope, so customer commands and ordinary output containing
310+
the marker do not match. The
307311
serial console remains guest-writable: a root guest can deliberately imitate
308312
the entire line and quarantine the VF its own instance holds; the quarantine
309313
only ever removes capacity, never touches the instance.

lib/devices/manager.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"log/slog"
78
"os"
89
"runtime"
910
"strings"
@@ -85,6 +86,12 @@ type manager struct {
8586
// NewManager creates a new device manager.
8687
// Use SetLivenessChecker after construction to enable accurate orphan detection.
8788
func NewManager(p *paths.Paths) Manager {
89+
// The VF health store lives with the device manager: constructing one
90+
// without loading the store would run placement against an empty,
91+
// non-persisting quarantine set.
92+
if err := InitVFHealth(p.VFHealthState()); err != nil {
93+
slog.Default().Error("failed to load VF health state; vGPU placement is disabled until the state file is repaired or removed", "error", err)
94+
}
8895
return &manager{
8996
paths: p,
9097
vfioBinder: NewVFIOBinder(),

lib/devices/vf_health.go

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,6 @@ var (
4545
vendorVFIOMu sync.Mutex
4646
)
4747

48-
// withVGPUPlacementLock serializes vendor VFIO placement with quarantine updates.
49-
func withVGPUPlacementLock(f func()) {
50-
vendorVFIOMu.Lock()
51-
defer vendorVFIOMu.Unlock()
52-
f()
53-
}
54-
5548
// InitVFHealth points the store at its state file and loads any persisted quarantines.
5649
func InitVFHealth(path string) error {
5750
vfHealth.mu.Lock()
@@ -115,10 +108,6 @@ func (s *vfHealthStore) ensureLoadedLocked() error {
115108
func (s *vfHealthStore) checkedAddresses() (map[string]struct{}, error) {
116109
s.mu.Lock()
117110
defer s.mu.Unlock()
118-
return s.checkedAddressesLocked()
119-
}
120-
121-
func (s *vfHealthStore) checkedAddressesLocked() (map[string]struct{}, error) {
122111
if err := s.ensureLoadedLocked(); err != nil {
123112
return nil, fmt.Errorf("VF health state unavailable: %w", err)
124113
}
@@ -131,13 +120,10 @@ func (s *vfHealthStore) checkedAddressesLocked() (map[string]struct{}, error) {
131120

132121
// AllocatableVFs returns the number of free VFs eligible for placement.
133122
func AllocatableVFs(framework VGPUFramework, vfs []VirtualFunction) (int, error) {
134-
vfHealth.mu.Lock()
135-
defer vfHealth.mu.Unlock()
136-
137123
if framework != VGPUFrameworkVendorVFIO {
138124
return countFreeVFs(vfs, nil), nil
139125
}
140-
quarantined, err := vfHealth.checkedAddressesLocked()
126+
quarantined, err := vfHealth.checkedAddresses()
141127
if err != nil {
142128
return 0, err
143129
}
@@ -157,14 +143,13 @@ func countFreeVFs(vfs []VirtualFunction, quarantined map[string]struct{}) int {
157143
return available
158144
}
159145

160-
// QuarantineVF records a wedge conviction and persists it, under the vGPU
161-
// placement lock so a convicted VF is never concurrently selected. A repeat
162-
// conviction leaves the record unchanged and reports existed=true.
146+
// QuarantineVF records a wedge conviction and persists it, under the vendor
147+
// VFIO placement lock so a convicted VF is never concurrently selected. A
148+
// repeat conviction leaves the record unchanged and reports existed=true.
163149
func QuarantineVF(q VFQuarantine) (existed bool, err error) {
164-
withVGPUPlacementLock(func() {
165-
existed, err = vfHealth.quarantine(q)
166-
})
167-
return existed, err
150+
vendorVFIOMu.Lock()
151+
defer vendorVFIOMu.Unlock()
152+
return vfHealth.quarantine(q)
168153
}
169154

170155
// VFHealthStoreUnavailable reports whether the persisted VF health state

lib/instances/storage.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,9 @@ func (m *manager) listMetadataFiles() ([]string, error) {
195195
}
196196

197197
// listMetadataFilesStrict returns readable metadata paths and joins any stat
198-
// errors other than absence.
198+
// errors other than absence. Fail-closed callers (the vGPU release claim scan
199+
// and startup reconcile protection) use it so an unreadable instance is an
200+
// error instead of silently missing.
199201
func (m *manager) listMetadataFilesStrict() ([]string, error) {
200202
files, statErr, err := m.walkMetadataFiles()
201203
return files, errors.Join(statErr, err)

lib/instances/vgpu_sentinel.go

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -140,29 +140,24 @@ func (c *VGPUSentinelController) Run(ctx context.Context) error {
140140
// The framework is fixed for the process lifetime, so one successful
141141
// probe settles the gate. A failed probe fails open (a transient
142142
// discovery error must not disable detection) and is retried each tick.
143-
vendor, known := c.probeVendorVFIO()
144-
if known && !vendor {
145-
return nil
146-
}
147-
if known {
148-
c.log.Info("vGPU sentinel controller started")
149-
}
150143
ticker := time.NewTicker(c.interval)
151144
defer ticker.Stop()
145+
var known bool
152146
for {
147+
if !known {
148+
var vendor bool
149+
vendor, known = c.probeVendorVFIO()
150+
if known && !vendor {
151+
return nil
152+
}
153+
if known {
154+
c.log.Info("vGPU sentinel controller started")
155+
}
156+
}
153157
select {
154158
case <-ctx.Done():
155159
return nil
156160
case <-ticker.C:
157-
if !known {
158-
vendor, known = c.probeVendorVFIO()
159-
if known && !vendor {
160-
return nil
161-
}
162-
if known {
163-
c.log.Info("vGPU sentinel controller started")
164-
}
165-
}
166161
c.scanOnce(ctx)
167162
}
168163
}
@@ -336,7 +331,7 @@ func (m *manager) listVGPUSentinelTargets(ctx context.Context) ([]vgpuSentinelTa
336331
return nil, err
337332
}
338333
if statErr != nil {
339-
logger.FromContext(ctx).WarnContext(ctx, "vGPU sentinel skipping unreadable instance metadata", "error", statErr)
334+
logger.FromContext(ctx).WarnContext(ctx, "vGPU sentinel cannot stat some instance metadata; their VFs are not scanned", "error", statErr)
340335
}
341336
targets := make([]vgpuSentinelTarget, 0, len(files))
342337
for _, file := range files {

lib/instances/vgpu_sentinel_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ func TestListVGPUSentinelTargetsSkipsUnstattableMetadata(t *testing.T) {
353353
require.NoError(t, err)
354354
require.Len(t, targets, 1)
355355
assert.Equal(t, "readable", targets[0].instanceID)
356-
assert.Contains(t, logs.String(), "vGPU sentinel skipping unreadable instance metadata")
356+
assert.Contains(t, logs.String(), "vGPU sentinel cannot stat some instance metadata; their VFs are not scanned")
357357
assert.Contains(t, logs.String(), "unreadable-a")
358358
assert.Contains(t, logs.String(), "unreadable-b")
359359
}

lib/providers/providers.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,6 @@ func ProvideNetworkManager(p *paths.Paths, cfg *config.Config) network.Manager {
9696

9797
// ProvideDeviceManager provides the device manager
9898
func ProvideDeviceManager(p *paths.Paths) devices.Manager {
99-
if err := devices.InitVFHealth(p.VFHealthState()); err != nil {
100-
slog.Default().Error("failed to load VF health state; vGPU placement is disabled until the state file is repaired or removed", "error", err)
101-
}
10299
return devices.NewManager(p)
103100
}
104101

lib/resources/gpu.go

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,16 @@ import (
1010
// GPUResourceStatus represents the GPU resource status for the API response.
1111
// Returns nil if no GPU is available on the host.
1212
type GPUResourceStatus struct {
13-
Mode string `json:"mode"` // "vgpu" or "passthrough"
14-
TotalSlots int `json:"total_slots"` // VFs for vGPU, physical GPUs for passthrough
15-
UsedSlots int `json:"used_slots"` // Slots currently in use
16-
AllocatableSlots int `json:"-"` // Healthy free slots used by admission control
17-
Profiles []devices.GPUProfile `json:"profiles,omitempty"` // vGPU mode only
18-
Devices []devices.PassthroughDevice `json:"devices,omitempty"` // passthrough mode only
13+
Mode string `json:"mode"` // "vgpu" or "passthrough"
14+
TotalSlots int `json:"total_slots"` // VFs for vGPU, physical GPUs for passthrough
15+
UsedSlots int `json:"used_slots"` // Slots currently in use
16+
AllocatableSlots int `json:"-"` // Healthy free slots used by admission control
17+
// AllocatableSlotsErr explains a zeroed AllocatableSlots caused by an
18+
// unreadable VF health store, so admission failures name the real cause
19+
// instead of reading as exhaustion.
20+
AllocatableSlotsErr string `json:"-"`
21+
Profiles []devices.GPUProfile `json:"profiles,omitempty"` // vGPU mode only
22+
Devices []devices.PassthroughDevice `json:"devices,omitempty"` // passthrough mode only
1923
}
2024

2125
// GetGPUStatus returns the current GPU resource status.
@@ -53,18 +57,19 @@ func getVGPUStatus(ctx context.Context, framework devices.VGPUFramework, vfs []d
5357
profiles = nil
5458
}
5559
allocatableSlots, err := devices.AllocatableVFs(framework, vfs)
56-
if err != nil {
57-
logger.FromContext(ctx).WarnContext(ctx, "failed to count allocatable vGPU slots; reporting none", "framework", framework, "error", err)
58-
allocatableSlots = 0
59-
}
60-
61-
return &GPUResourceStatus{
60+
status := &GPUResourceStatus{
6261
Mode: string(devices.GPUModeVGPU),
6362
TotalSlots: len(vfs),
6463
UsedSlots: usedSlots,
6564
AllocatableSlots: allocatableSlots,
6665
Profiles: profiles,
6766
}
67+
if err != nil {
68+
logger.FromContext(ctx).WarnContext(ctx, "failed to count allocatable vGPU slots; reporting none", "framework", framework, "error", err)
69+
status.AllocatableSlots = 0
70+
status.AllocatableSlotsErr = err.Error()
71+
}
72+
return status
6873
}
6974

7075
// getPassthroughStatus returns GPU status for whole-GPU passthrough mode.

lib/resources/gpu_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ func TestGetVGPUStatusFailsClosedWhenVFHealthIsUnavailable(t *testing.T) {
2424

2525
status := getVGPUStatus(context.Background(), devices.VGPUFrameworkVendorVFIO, []devices.VirtualFunction{{PCIAddress: "0000:82:00.4"}})
2626
assert.Zero(t, status.AllocatableSlots)
27+
assert.Contains(t, status.AllocatableSlotsErr, "VF health state unavailable",
28+
"admission must be able to name the real cause instead of reporting exhaustion")
2729
}
2830

2931
func TestReserveAllocationUsesAllocatableGPUSlots(t *testing.T) {
@@ -42,6 +44,11 @@ func TestReserveAllocationUsesAllocatableGPUSlots(t *testing.T) {
4244
err := mgr.ValidateAllocation(ctx, 0, 0, 0, 0, 0, 0, true)
4345
require.ErrorContains(t, err, "no allocatable vgpu slots")
4446

47+
status.AllocatableSlotsErr = "VF health state unavailable: read failed"
48+
err = mgr.ValidateAllocation(ctx, 0, 0, 0, 0, 0, 0, true)
49+
require.ErrorContains(t, err, "vGPU placement is disabled: VF health state unavailable")
50+
status.AllocatableSlotsErr = ""
51+
4552
status.AllocatableSlots = 1
4653
require.NoError(t, mgr.ReserveAllocation(ctx, "pending-a", 0, 0, 0, 0, 0, 0, true))
4754
err = mgr.ReserveAllocation(ctx, "pending-b", 0, 0, 0, 0, 0, 0, true)

lib/resources/resource.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,9 @@ func (m *Manager) validateAllocationLocked(ctx context.Context, excludeID string
697697
}
698698
availableSlots := gpuStatus.AllocatableSlots - pending.GPUSlots
699699
if availableSlots < req.GPUSlots {
700+
if gpuStatus.AllocatableSlotsErr != "" {
701+
return fmt.Errorf("insufficient GPU: vGPU placement is disabled: %s", gpuStatus.AllocatableSlotsErr)
702+
}
700703
if availableSlots <= 0 {
701704
return fmt.Errorf("insufficient GPU: no allocatable %s slots available (%d total, %d in use)",
702705
gpuStatus.Mode, gpuStatus.TotalSlots, gpuStatus.UsedSlots)

0 commit comments

Comments
 (0)