mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-20 11:21:49 +00:00
78d703da78
* feat(gpu): add NVIDIA MIG multi-instance GPU support
Partitions MIG-capable GPUs (A100, A30, H100) into isolated slices exposed
as mdev devices instead of whole-GPU VFIO passthrough. VMs attach via
-device vfio-pci,sysfsdev=<mdev-path> using the GPUAttachment discriminated
union (PCIAddress for whole-GPU, MdevPath for MIG).
Key changes:
- gpu: new MIGProfile/MIGInstance types, IsMIGCapable model map, mig.go ops
(EnableMIGMode, DisableMIGMode, CreateInstances, ListInstances,
DestroyAllInstances, enrichMdevPaths)
- gpu.Manager: Claim() returns (*GPUDevice, *MIGInstance, error); AddMIGInstances,
ReclaimByMdev for restart recovery; MarkMIGFailed per-slice
- daemon: buildGPUPool helper partitions devices into whole-GPU vs MIG pools;
MIGProfile config field selects profile per node or per GPU override
- vm.VM: GPUAttachments []gpu.GPUAttachment replaces GPUPCIAddresses []string
- admin CLI: spx admin gpu mig status/enable/disable subcommands with
admin.SetMIGProfile for spinifex.toml writes
- docs: COMMANDS.md updated with three new MIG management command rows
* test(gpu): unit tests for MIG parsers and pool manager
Cover all pure parsing functions (parseMIGMemory, isCapacityExhaustedError,
extractProfileFields/GIFields, parseMIGProfiles, parseMIGInstances,
parseCreatedGIID/CIID) with realistic nvidia-smi fixture output.
Manager MIG tests cover AddMIGInstances, Claim/Release on mdev-backed entries,
MarkMIGFailed, ReclaimByMdev, and interaction with whole-GPU MarkFailed — all
using fake mdev directories in t.TempDir() so no GPU hardware is required.
* feat(gpu): dynamic MIG profile-per-request with on-demand GI/CI lifecycle
Replaces static config-driven MIG pre-carving with a dynamic model where
the MIG profile is specified per RunInstances call via mig.<profile>
instance type names (e.g. mig.1g.10gb) that mirror nvidia-smi nomenclature
directly.
- Manager.Claim(instanceID, profileName): fast path claims a pre-carved
slice; slow path dequeues a free MIG GPU, releases the lock, calls
nvidia-smi mig -cgi/-cci to carve all slices, then reclaims the lock to
commit and return the first slice
- Manager.Release: last release on a dynamically-carved GPU calls
DestroyAllInstances and returns the GPU to freeMIGGPUs, allowing a
different profile on the next claim
- AddMIGGPU(dev): registers a fresh MIG GPU for dynamic allocation;
AddMIGInstances (restart recovery) keeps its no-destroy semantics
- GenerateMIGTypes: produces mig.<profile> InstanceTypeInfo entries from
gpu.ListProfiles at daemon startup; IsMIGType/MIGProfileFromType let
callers inspect these names
- buildGPUPool: calls ListInstances for recovery, AddMIGGPU for fresh
GPUs, ListProfiles to collect MIG profile specs for type generation;
removes setupMIGInstances and migProfileForDevice
- GPUClaimer.Claim gains profileName param; daemonGPUClaimer, service_impl
call sites, and fakeGPUClaimer all updated
* fix(gpu): correct mdev parent detection and make --profile optional for mig enable
enrichMdevPaths was using os.Readlink on "parent_bus_0" (a non-existent
sysfs attribute) and then on ".." (a directory, not a symlink) — both
would fail on real NVIDIA hardware, breaking CreateInstances and
ListInstances on daemon restart recovery. Fix: read the mdev UUID's own
symlink target, which always encodes the parent GPU's PCI address in its
path (e.g. .../0000:01:00.0/<uuid>).
Make basePath injectable in enrichMdevPaths so the fix is covered by a
new set of unit tests using temp-dir symlinks (TestEnrichMdevPaths_*).
Remove the MarkFlagRequired("profile") from "spx admin gpu mig enable":
Phase 2 is fully dynamic — the daemon carves slices on demand at
RunInstances time. Without --profile, the command just enables MIG mode
and destroys any pre-carved slices so the daemon starts with a free GPU.
With --profile, behaviour is unchanged (pre-carve a static pool).
* chore: writeups for smci and arcee
* test: add coverage for MIG exec, manager slow-path, instancetypes and admin
- Add mig_exec_test.go with fake nvidia-smi harness covering Enable/Disable MIGMode,
IsMIGModeEnabled, DestroyAllInstances, ListProfiles, CreateInstances, ListInstances
- Extend manager_mig_test.go with slow-path Claim/Release tests using fake nvidia-smi
- Add generate_test.go coverage for IsMIGType, MIGProfileFromType, GenerateMIGTypes
- Add admin_test.go coverage for SetMIGProfile (create, update, idempotent, error paths)
- Make mdevBasePath a var to allow sysfs injection in tests
- Add json tags to GPUAttachment fields to satisfy musttag linter
Diff coverage: 75.6% (threshold: 60%).
* fixed gpu passthrough doc
* Revert "fixed gpu passthrough doc"
This reverts commit 1e20f5ab13.
319 lines
11 KiB
Go
319 lines
11 KiB
Go
package daemon
|
|
|
|
import (
|
|
"encoding/json"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"github.com/mulgadc/spinifex/spinifex/config"
|
|
"github.com/mulgadc/spinifex/spinifex/gpu"
|
|
"github.com/mulgadc/spinifex/spinifex/tags"
|
|
"github.com/mulgadc/spinifex/spinifex/types"
|
|
"github.com/mulgadc/spinifex/spinifex/vm"
|
|
"github.com/nats-io/nats.go"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// newHookTestDaemon returns a Daemon with the minimum surface required to
|
|
// drive onInstanceUpHook / onInstanceDownHook: a live NATS connection and an
|
|
// initialised natsSubscriptions map. The hook handlers themselves are never
|
|
// invoked here — the assertions inspect the subscription map and topics.
|
|
func newHookTestDaemon(t *testing.T) (*Daemon, *nats.Conn) {
|
|
t.Helper()
|
|
nc, err := nats.Connect(sharedNATSURL)
|
|
require.NoError(t, err)
|
|
t.Cleanup(nc.Close)
|
|
d := &Daemon{
|
|
natsConn: nc,
|
|
natsSubscriptions: make(map[string]*nats.Subscription),
|
|
}
|
|
return d, nc
|
|
}
|
|
|
|
func TestOnInstanceUpHook_RegistersBothPerInstanceTopics(t *testing.T) {
|
|
d, _ := newHookTestDaemon(t)
|
|
instance := &vm.VM{ID: "i-up-basic"}
|
|
|
|
require.NoError(t, d.onInstanceUpHook()(instance))
|
|
|
|
cmdSub, ok := d.natsSubscriptions[instance.ID]
|
|
require.True(t, ok, "ec2.cmd subscription must be registered under instance ID")
|
|
assert.Equal(t, "ec2.cmd.i-up-basic", cmdSub.Subject)
|
|
|
|
consoleSub, ok := d.natsSubscriptions[instance.ID+".console"]
|
|
require.True(t, ok, "console subscription must be registered under <id>.console key")
|
|
assert.Equal(t, "ec2.i-up-basic.GetConsoleOutput", consoleSub.Subject)
|
|
}
|
|
|
|
func TestOnInstanceUpHook_ReplacesExistingSubsOnDoubleUp(t *testing.T) {
|
|
d, _ := newHookTestDaemon(t)
|
|
instance := &vm.VM{ID: "i-up-twice"}
|
|
|
|
require.NoError(t, d.onInstanceUpHook()(instance))
|
|
first := d.natsSubscriptions[instance.ID]
|
|
firstConsole := d.natsSubscriptions[instance.ID+".console"]
|
|
require.NotNil(t, first)
|
|
require.NotNil(t, firstConsole)
|
|
|
|
require.NoError(t, d.onInstanceUpHook()(instance))
|
|
second := d.natsSubscriptions[instance.ID]
|
|
secondConsole := d.natsSubscriptions[instance.ID+".console"]
|
|
require.NotNil(t, second)
|
|
require.NotNil(t, secondConsole)
|
|
|
|
// Second call must have unsubscribed the originals (so they're no longer
|
|
// receiving on the topic) and replaced the map entries with fresh subs.
|
|
assert.False(t, first.IsValid(), "first command sub should be unsubscribed")
|
|
assert.False(t, firstConsole.IsValid(), "first console sub should be unsubscribed")
|
|
assert.True(t, second.IsValid(), "second command sub should be live")
|
|
assert.True(t, secondConsole.IsValid(), "second console sub should be live")
|
|
assert.NotSame(t, first, second, "command sub map entry must be replaced")
|
|
assert.NotSame(t, firstConsole, secondConsole, "console sub map entry must be replaced")
|
|
}
|
|
|
|
func TestOnInstanceDownHook_UnsubscribesAndDeletes(t *testing.T) {
|
|
d, _ := newHookTestDaemon(t)
|
|
instance := &vm.VM{ID: "i-down"}
|
|
|
|
require.NoError(t, d.onInstanceUpHook()(instance))
|
|
cmdSub := d.natsSubscriptions[instance.ID]
|
|
consoleSub := d.natsSubscriptions[instance.ID+".console"]
|
|
require.NotNil(t, cmdSub)
|
|
require.NotNil(t, consoleSub)
|
|
|
|
d.onInstanceDownHook()(instance.ID)
|
|
|
|
_, cmdPresent := d.natsSubscriptions[instance.ID]
|
|
_, consolePresent := d.natsSubscriptions[instance.ID+".console"]
|
|
assert.False(t, cmdPresent, "command sub must be deleted from map")
|
|
assert.False(t, consolePresent, "console sub must be deleted from map")
|
|
assert.False(t, cmdSub.IsValid(), "command sub must be unsubscribed")
|
|
assert.False(t, consoleSub.IsValid(), "console sub must be unsubscribed")
|
|
}
|
|
|
|
func TestOnInstanceDownHook_NoOpWhenAbsent(t *testing.T) {
|
|
d, _ := newHookTestDaemon(t)
|
|
|
|
// Down on an unknown instance must not panic and must leave the map empty.
|
|
d.onInstanceDownHook()("i-never-up")
|
|
|
|
assert.Empty(t, d.natsSubscriptions)
|
|
}
|
|
|
|
// When the daemon's gpuManager is unset, the hook must still register NATS
|
|
// subscriptions and ignore the GPUPCIAddresses on the VM.
|
|
func TestOnInstanceUpHook_NoGPUManager_SkipsReclaim(t *testing.T) {
|
|
d, _ := newHookTestDaemon(t)
|
|
instance := &vm.VM{ID: "i-up-nogpu", GPUAttachments: []gpu.GPUAttachment{{PCIAddress: "0000:01:00.0"}}}
|
|
|
|
require.NoError(t, d.onInstanceUpHook()(instance))
|
|
require.Contains(t, d.natsSubscriptions, instance.ID)
|
|
}
|
|
|
|
// With a gpuManager configured but a CPU-only instance, the hook must not
|
|
// touch the GPU pool. We use the AllocatedCount as the observable: an
|
|
// unintended Reclaim would bump it.
|
|
func TestOnInstanceUpHook_NoGPUAddress_SkipsReclaim(t *testing.T) {
|
|
d, _ := newHookTestDaemon(t)
|
|
d.gpuManager = gpu.NewManager(nil)
|
|
instance := &vm.VM{ID: "i-up-cpu"}
|
|
|
|
require.NoError(t, d.onInstanceUpHook()(instance))
|
|
assert.Equal(t, 0, d.gpuManager.AllocatedCount(),
|
|
"hook must not call Reclaim for instances without GPUPCIAddresses")
|
|
}
|
|
|
|
// With a gpuManager that has no entries, calling Reclaim for an instance
|
|
// with a GPUPCIAddresses entry will fail inside the manager. The hook logs a warning
|
|
// and returns nil — the NATS subscriptions must still register so the
|
|
// reconnect path doesn't roll back.
|
|
func TestOnInstanceUpHook_GPUReclaimError_DoesNotPropagate(t *testing.T) {
|
|
d, _ := newHookTestDaemon(t)
|
|
d.gpuManager = gpu.NewManager(nil)
|
|
instance := &vm.VM{ID: "i-up-gpu-missing", GPUAttachments: []gpu.GPUAttachment{{PCIAddress: "0000:99:00.0"}}}
|
|
|
|
require.NoError(t, d.onInstanceUpHook()(instance))
|
|
require.Contains(t, d.natsSubscriptions, instance.ID)
|
|
}
|
|
|
|
func TestOnInstanceDownHook_OnlyRemovesTargetedInstance(t *testing.T) {
|
|
d, _ := newHookTestDaemon(t)
|
|
keep := &vm.VM{ID: "i-keep"}
|
|
drop := &vm.VM{ID: "i-drop"}
|
|
|
|
require.NoError(t, d.onInstanceUpHook()(keep))
|
|
require.NoError(t, d.onInstanceUpHook()(drop))
|
|
require.Len(t, d.natsSubscriptions, 4)
|
|
|
|
d.onInstanceDownHook()(drop.ID)
|
|
|
|
assert.Len(t, d.natsSubscriptions, 2)
|
|
assert.NotNil(t, d.natsSubscriptions[keep.ID])
|
|
assert.NotNil(t, d.natsSubscriptions[keep.ID+".console"])
|
|
_, dropPresent := d.natsSubscriptions[drop.ID]
|
|
assert.False(t, dropPresent)
|
|
}
|
|
|
|
func TestVolumeMounterAdapter_MountOne(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
responder func(t *testing.T, msg *nats.Msg)
|
|
skipSub bool
|
|
wantErr bool
|
|
wantErrSub string
|
|
wantErrIs error
|
|
wantNBDURI string
|
|
initialURI string
|
|
}{
|
|
{
|
|
name: "HappyPath_UpdatesNBDURI",
|
|
responder: func(t *testing.T, msg *nats.Msg) {
|
|
resp := types.EBSMountResponse{URI: "nbd://mounted-vol"}
|
|
data, err := json.Marshal(resp)
|
|
require.NoError(t, err)
|
|
require.NoError(t, msg.Respond(data))
|
|
},
|
|
wantNBDURI: "nbd://mounted-vol",
|
|
},
|
|
{
|
|
name: "NATSNoResponders_ReturnsError",
|
|
skipSub: true,
|
|
wantErr: true,
|
|
wantErrSub: "ebs.mount NATS request",
|
|
},
|
|
{
|
|
name: "UnmarshalFailure_ReturnsError",
|
|
responder: func(t *testing.T, msg *nats.Msg) {
|
|
require.NoError(t, msg.Respond([]byte("not json")))
|
|
},
|
|
wantErr: true,
|
|
wantErrSub: "unmarshal ebs.mount response",
|
|
},
|
|
{
|
|
name: "ResponseError_IncludedInError",
|
|
responder: func(t *testing.T, msg *nats.Msg) {
|
|
resp := types.EBSMountResponse{Error: "boom"}
|
|
data, err := json.Marshal(resp)
|
|
require.NoError(t, err)
|
|
require.NoError(t, msg.Respond(data))
|
|
},
|
|
wantErr: true,
|
|
wantErrSub: "boom",
|
|
},
|
|
{
|
|
name: "EmptyURI_ReturnsErrMountAmbiguous",
|
|
responder: func(t *testing.T, msg *nats.Msg) {
|
|
resp := types.EBSMountResponse{URI: ""}
|
|
data, err := json.Marshal(resp)
|
|
require.NoError(t, err)
|
|
require.NoError(t, msg.Respond(data))
|
|
},
|
|
wantErr: true,
|
|
wantErrIs: vm.ErrMountAmbiguous,
|
|
},
|
|
{
|
|
name: "EmptyURI_PreservesInitialNBDURIOnFailure",
|
|
responder: func(t *testing.T, msg *nats.Msg) {
|
|
resp := types.EBSMountResponse{URI: ""}
|
|
data, err := json.Marshal(resp)
|
|
require.NoError(t, err)
|
|
require.NoError(t, msg.Respond(data))
|
|
},
|
|
initialURI: "nbd://stale",
|
|
wantErr: true,
|
|
wantErrIs: vm.ErrMountAmbiguous,
|
|
wantNBDURI: "nbd://stale",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
daemon := createTestDaemon(t, sharedNATSURL)
|
|
adapter := newVolumeMounterAdapter(daemon.natsConn, daemon.node, nil)
|
|
|
|
if !tt.skipSub {
|
|
sub, err := daemon.natsConn.Subscribe("ebs.node-1.mount", func(msg *nats.Msg) {
|
|
tt.responder(t, msg)
|
|
})
|
|
require.NoError(t, err)
|
|
defer sub.Unsubscribe()
|
|
}
|
|
|
|
req := &types.EBSRequest{
|
|
Name: "vol-mountone",
|
|
DeviceName: "/dev/sdf",
|
|
NBDURI: tt.initialURI,
|
|
}
|
|
|
|
err := adapter.MountOne(req)
|
|
if tt.wantErr {
|
|
require.Error(t, err)
|
|
if tt.wantErrSub != "" {
|
|
assert.Contains(t, err.Error(), tt.wantErrSub)
|
|
}
|
|
if tt.wantErrIs != nil {
|
|
assert.ErrorIs(t, err, tt.wantErrIs)
|
|
}
|
|
assert.Equal(t, tt.wantNBDURI, req.NBDURI,
|
|
"NBDURI must not be overwritten on failure")
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.wantNBDURI, req.NBDURI,
|
|
"happy path must write resolved NBDURI back to req")
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- ReleaseGPU ---
|
|
|
|
// ReleaseGPU is a no-op when the daemon has no GPU manager.
|
|
func TestReleaseGPU_NoManager_NoOp(t *testing.T) {
|
|
d := &Daemon{}
|
|
a := newInstanceCleanerAdapter(d)
|
|
instance := &vm.VM{ID: "i-nogpu", GPUAttachments: []gpu.GPUAttachment{{PCIAddress: "0000:03:00.0"}}}
|
|
// Must not panic.
|
|
a.ReleaseGPU(instance)
|
|
}
|
|
|
|
// ReleaseGPU is a no-op for instances without a GPU allocation.
|
|
func TestReleaseGPU_NoAddresses_NoOp(t *testing.T) {
|
|
mgr := gpu.NewManager(nil)
|
|
d := &Daemon{gpuManager: mgr}
|
|
a := newInstanceCleanerAdapter(d)
|
|
instance := &vm.VM{ID: "i-nogpu"}
|
|
// Must not panic or error.
|
|
a.ReleaseGPU(instance)
|
|
}
|
|
|
|
// ReleaseGPU logs a warning when the manager returns an error (instance not claimed).
|
|
func TestReleaseGPU_ManagerError_LogsWarning(t *testing.T) {
|
|
mgr := gpu.NewManager(nil)
|
|
d := &Daemon{gpuManager: mgr}
|
|
a := newInstanceCleanerAdapter(d)
|
|
// GPU address set but no claim registered — Release returns an error.
|
|
instance := &vm.VM{ID: "i-unclaimed", GPUAttachments: []gpu.GPUAttachment{{PCIAddress: "0000:03:00.0"}}}
|
|
// Must not panic; the error is logged as a warning.
|
|
a.ReleaseGPU(instance)
|
|
}
|
|
|
|
// TestBuildVMManagerDeps_WiresBeforeInstanceRelaunch guards the single line
|
|
// in buildVMManagerDeps that routes the recovery hook to
|
|
// refreshSystemInstanceState. Dropping it would surface only in cell-18.
|
|
func TestBuildVMManagerDeps_WiresBeforeInstanceRelaunch(t *testing.T) {
|
|
d := &Daemon{config: &config.Config{}, vmMgr: vm.NewManager()}
|
|
deps := d.buildVMManagerDeps()
|
|
require.NotNil(t, deps.Hooks.BeforeInstanceRelaunch)
|
|
|
|
wantPC := reflect.ValueOf(d.refreshSystemInstanceState).Pointer()
|
|
gotPC := reflect.ValueOf(deps.Hooks.BeforeInstanceRelaunch).Pointer()
|
|
assert.Equal(t, wantPC, gotPC, "hook must point at refreshSystemInstanceState")
|
|
|
|
// Sanity: the wired hook is callable and returns nil for non-ELBv2 VMs.
|
|
require.NoError(t, deps.Hooks.BeforeInstanceRelaunch(&vm.VM{ID: "i-noop", ManagedBy: ""}))
|
|
require.Error(t, deps.Hooks.BeforeInstanceRelaunch(&vm.VM{ID: "i-svc", ManagedBy: tags.ManagedByELBv2}),
|
|
"ELBv2 VM with nil elbv2Service must error rather than silently no-op")
|
|
}
|