From d3ba30d01eb3c5e4bd22d8af85ae9a8351cf02e0 Mon Sep 17 00:00:00 2001 From: Oleg Zhurakivskyy Date: Thu, 10 Sep 2026 01:58:10 +0300 Subject: [PATCH 1/2] topology-aware: Temporary changes for gofmbt These temporary changes will be dropped once the gofmbt callback registrations are upstreamed. Signed-off-by: Oleg Zhurakivskyy --- go.mod | 1 + go.sum | 2 ++ .../topology-aware/n4c16/test06-fuzz/generate.go | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index eebdbe85b..2a12bf728 100644 --- a/go.mod +++ b/go.mod @@ -76,6 +76,7 @@ require ( github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/runtime-spec v1.3.0 // indirect + github.com/ozhuraki/gofmbt v0.0.0-20260909154336-397d7f3ad038 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect diff --git a/go.sum b/go.sum index 19e7836e8..1e2738c57 100644 --- a/go.sum +++ b/go.sum @@ -118,6 +118,8 @@ github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/opencontainers/runtime-spec v1.3.0 h1:YZupQUdctfhpZy3TM39nN9Ika5CBWT5diQ8ibYCRkxg= github.com/opencontainers/runtime-spec v1.3.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/ozhuraki/gofmbt v0.0.0-20260909154336-397d7f3ad038 h1:tDzd/dxErr46veQSvk22SoslVpMu+YrEpnZJoy2d5r0= +github.com/ozhuraki/gofmbt v0.0.0-20260909154336-397d7f3ad038/go.mod h1:Aml7A4F6EQMuEO5eFcJ86QqdWEEwBtn09Wtc663NTp4= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= diff --git a/test/e2e/policies.test-suite/topology-aware/n4c16/test06-fuzz/generate.go b/test/e2e/policies.test-suite/topology-aware/n4c16/test06-fuzz/generate.go index d814b0a26..0f722883a 100644 --- a/test/e2e/policies.test-suite/topology-aware/n4c16/test06-fuzz/generate.go +++ b/test/e2e/policies.test-suite/topology-aware/n4c16/test06-fuzz/generate.go @@ -8,7 +8,7 @@ import ( "sort" "strings" - m "github.com/askervin/gofmbt/gofmbt" + m "github.com/ozhuraki/gofmbt/gofmbt" ) type PodResources struct { From 2f7f6119c66c9b9de083afec1b50ec30b38b85d5 Mon Sep 17 00:00:00 2001 From: Oleg Zhurakivskyy Date: Wed, 9 Sep 2026 22:32:17 +0300 Subject: [PATCH 2/2] topology-aware: Add gofmbt based test for libmem allocations Signed-off-by: Oleg Zhurakivskyy --- .../topology-aware/policy/libmem_test.go | 349 +++++++++++++++++- 1 file changed, 346 insertions(+), 3 deletions(-) diff --git a/cmd/plugins/topology-aware/policy/libmem_test.go b/cmd/plugins/topology-aware/policy/libmem_test.go index d0032e22c..045514e1c 100644 --- a/cmd/plugins/topology-aware/policy/libmem_test.go +++ b/cmd/plugins/topology-aware/policy/libmem_test.go @@ -15,8 +15,12 @@ package topologyaware import ( + "flag" + "fmt" "os" "path" + "runtime" + "sort" "strings" "testing" @@ -24,11 +28,29 @@ import ( policyapi "github.com/containers/nri-plugins/pkg/resmgr/policy" system "github.com/containers/nri-plugins/pkg/sysfs" "github.com/containers/nri-plugins/pkg/testutils" + "github.com/go-logr/logr" + m "github.com/ozhuraki/gofmbt/gofmbt" + "k8s.io/klog/v2" ) -// setupTestPolicy creates a policy from the server sysfs testdata. -func setupTestPolicy(t *testing.T) (*policy, string) { +// LibmemState is the abstract model state for TestLibmemGofmbt2. It +// tracks how many bytes are free and which named allocations are live. +type LibmemState struct { + freeBytes int64 + allocs map[string]int64 // abstract name -> allocated size +} + +// resolveSysfsPath returns the sysfs path to use for the test, preferring an +// already-unpacked testdata/sysfs/server/sys directory if present, and +// otherwise unpacking testdata/sysfs.tar.bz2 into a fresh temp dir. dir is +// non-empty only when a temp dir was created and must be cleaned up by the +// caller. +func resolveSysfsPath(t *testing.T) (sysPath, dir string) { t.Helper() + const preUnpacked = "testdata/sysfs/server/sys" + if _, err := os.Stat(preUnpacked); err == nil { + return preUnpacked, "" + } dir, err := os.MkdirTemp("", "nri-libmem-test-") if err != nil { t.Fatalf("failed to create temp dir: %v", err) @@ -39,8 +61,18 @@ func setupTestPolicy(t *testing.T) (*policy, string) { } t.Fatalf("failed to uncompress testdata: %v", err) } + return path.Join(dir, "sysfs", "server", "sys"), dir +} + +// setupTestPolicy creates a policy from the server sysfs testdata. +// If testdata/sysfs/server/sys already exists in the current directory it is +// used directly and the returned dir is empty (caller must not delete it). +// Otherwise the tarball is unpacked into a temp dir and that dir is returned +// so the caller can clean it up with removeAll. +func setupTestPolicy(t *testing.T) (*policy, string) { + t.Helper() + sysPath, dir := resolveSysfsPath(t) - sysPath := path.Join(dir, "sysfs", "server", "sys") sys, err := system.DiscoverSystemAt(sysPath) if err != nil { if rerr := os.RemoveAll(dir); rerr != nil { @@ -62,9 +94,42 @@ func setupTestPolicy(t *testing.T) (*policy, string) { } t.Fatalf("failed to setup policy: %v", err) } + printSystemDRAM(sys) return p, dir } +// printSystemDRAM prints DRAM capacity per NUMA node and the total. +func printSystemDRAM(sys system.System) { + var total uint64 + for _, id := range sys.NodeIDs() { + n := sys.Node(id) + if n.GetMemoryType() != system.MemoryTypeDRAM { + continue + } + info, err := n.MemoryInfo() + if err != nil || info == nil { + continue + } + fmt.Printf(" NUMA node %d DRAM: %s\n", id, formatBytes(info.MemTotal)) + total += info.MemTotal + } + fmt.Printf(" DRAM total: %s\n", formatBytes(total)) +} + +// formatBytes formats a byte count in a human-readable form (GiB/MiB/KiB/B). +func formatBytes(b uint64) string { + switch { + case b >= 1<<30: + return fmt.Sprintf("%.1f GiB", float64(b)/float64(1<<30)) + case b >= 1<<20: + return fmt.Sprintf("%.1f MiB", float64(b)/float64(1<<20)) + case b >= 1<<10: + return fmt.Sprintf("%.1f KiB", float64(b)/float64(1<<10)) + default: + return fmt.Sprintf("%d B", b) + } +} + // TestLibmemGetMemOfferByHintsMemoryPreserve verifies that getMemOfferByHints // returns an error immediately when memoryPreserve is requested. func TestLibmemGetMemOfferByHintsMemoryPreserve(t *testing.T) { @@ -118,6 +183,110 @@ func TestLibmemGetMemOfferByHintsNoHints(t *testing.T) { } } +// mallocSeq is used to generate unique container IDs in malloc. +var mallocSeq int + +// mallocSizeByID tracks the allocated size per container ID so free() can print it. +var mallocSizeByID = map[string]int64{} + +// funcName returns the package-unqualified function name for pc. +func funcName(pc uintptr) string { + full := runtime.FuncForPC(pc).Name() + short := full[strings.LastIndex(full, "/")+1:] + return short[strings.Index(short, ".")+1:] +} + +// printCallers prints calling function names. +func printCallers(depth int) { + for i := 2; i <= depth+1; i++ { + pc, _, _, ok := runtime.Caller(i) + if !ok { + break + } + fmt.Printf(" %s()", funcName(pc)) + } +} + +// malloc allocates memory of the given size on a leaf DRAM node of the policy +// and returns the container ID of the committed allocation. +func malloc(p *policy, size int64) (string, error) { + mallocSeq++ + id := fmt.Sprintf("%d", mallocSeq) + + fmt.Printf("malloc(%dGB)", size>>30) + if fmbtV >= 1 { + fmt.Printf(" id=%s", id) + } + printCallers(callerDepth) + fmt.Println() + + var pool Node + for _, n := range p.pools { + if n.IsLeafNode() && n.HasMemoryType(memoryDRAM) { + pool = n + break + } + } + if pool == nil { + return "", fmt.Errorf("no leaf DRAM node found in test system") + } + ctr := &mockContainer{returnValueForGetID: id} + req := &request{ + memType: memoryDRAM, + memReq: size, + container: ctr, + } + offer, err := p.getMemOffer(pool, req) + if err != nil { + return "", fmt.Errorf("getMemOffer failed: %w", err) + } + if _, _, err := offer.Commit(); err != nil { + return "", fmt.Errorf("Offer.Commit() failed: %w", err) + } + mallocSizeByID[id] = size + return id, nil +} + +// free releases a previously committed memory allocation for the given container ID. +func free(p *policy, id string) error { + if size, ok := mallocSizeByID[id]; ok { + fmt.Printf("free(%dGB)", size>>30) + } else { + fmt.Printf("free(unknown id)") + } + if fmbtV >= 1 { + fmt.Printf(" id=%s", id) + } + printCallers(callerDepth) + fmt.Println() + err := p.releaseMem(id) + if err == nil { + delete(mallocSizeByID, id) + } + return err +} + +// TestLibmemReleaseMem verifies that releaseMem releases a previously committed +// memory allocation, and returns an error for an unknown ID. +func TestLibmemReleaseMem(t *testing.T) { + p, dir := setupTestPolicy(t) + defer removeAll(t, dir) + + id, err := malloc(p, 64*1024*1024) // 64 MiB + if err != nil { + t.Fatalf("malloc failed: %v", err) + } + + if err := free(p, id); err != nil { + t.Errorf("free failed for known ID: %v", err) + } + + // Releasing the same ID again should return an error (unknown request). + if err := free(p, id); err == nil { + t.Error("expected error releasing unknown ID, got nil") + } +} + // TestLibmemPoolZoneCapacityAndFree verifies that poolZoneCapacity returns a // positive value and that poolZoneFree does not exceed it. func TestLibmemPoolZoneCapacityAndFree(t *testing.T) { @@ -145,3 +314,177 @@ func TestLibmemPoolZoneCapacityAndFree(t *testing.T) { t.Errorf("expected 0 <= free (%d) <= capacity (%d)", free, capacity) } } + +func (s *LibmemState) String() string { + names := make([]string, 0, len(s.allocs)) + for name := range s.allocs { + names = append(names, name) + } + sort.Strings(names) + return fmt.Sprintf("[free:%dGB allocs:[%s]]", s.freeBytes>>30, strings.Join(names, " ")) +} + +var ( + maxLibmem2Steps int + libmem2Search int + callerDepth int + fmbtV int +) + +// init registers flags and switches CommandLine to ContinueOnError so that +// flags passed via -args on the command line are accepted. +func init() { + flag.CommandLine.Init(os.Args[0], flag.ContinueOnError) + flag.IntVar(&maxLibmem2Steps, "libmem2-steps", 1000, "number of test steps for TestLibmemGofmbt2") + flag.IntVar(&libmem2Search, "libmem2-search-depth", 4, "look-ahead depth for TestLibmemGofmbt2") + flag.IntVar(&callerDepth, "caller-depth", 1, "number of caller frames printed by malloc() and free()") + flag.IntVar(&fmbtV, "fmbt-v", 0, "verbosity for TestLibmemGofmbt2: 1=basic, 2=include caller info in mallocFn/freeFn") +} + +// TestLibmemGofmbt uses gofmbt model-based testing to drive malloc/free +// sequences against the policy, verifying that all operations succeed. +func TestLibmemGofmbt(t *testing.T) { + klog.SetLogger(logr.Discard()) + p, dir := setupTestPolicy(t) + klog.ClearLogger() + defer removeAll(t, dir) + + allocNames := []string{"02GB", "04GB", "08GB", "16GB", "32GB"} + allocSizes := map[string]int64{ + "02GB": 2 << 30, + "04GB": 4 << 30, + "08GB": 8 << 30, + "16GB": 16 << 30, + "32GB": 32 << 30, + } + + var totalAllocBytes int64 + for _, size := range allocSizes { + totalAllocBytes += size + } + + allocIDs := map[string]string{} // abstract name -> real container ID + + doMalloc := func(name string) (string, error) { + id, err := malloc(p, allocSizes[name]) + if err == nil { + allocIDs[name] = id + } + return id, err + } + + doFree := func(name string) error { + id, ok := allocIDs[name] + if !ok { + return nil + } + err := free(p, id) + if err == nil { + delete(allocIDs, name) + } + return err + } + + mallocFn := func(name string, size int64) m.StateChange { + return func(curr m.State) m.State { + s := curr.(*LibmemState) + if _, ok := s.allocs[name]; ok || s.freeBytes < size { + return nil + } + newAllocs := make(map[string]int64, len(s.allocs)+1) + for k, v := range s.allocs { + newAllocs[k] = v + } + newAllocs[name] = size + if fmbtV >= 2 { + pc, _, _, _ := runtime.Caller(1) + fmt.Printf("mallocFn(%dGB) called from %s\n", size>>30, runtime.FuncForPC(pc).Name()) + } else if fmbtV == 1 { + fmt.Printf("mallocFn(%dGB)\n", size>>30) + } + return &LibmemState{freeBytes: s.freeBytes - size, allocs: newAllocs} + } + } + + freeFn := func(name string) m.StateChange { + return func(curr m.State) m.State { + s := curr.(*LibmemState) + size, ok := s.allocs[name] + if !ok { + return nil + } + newAllocs := make(map[string]int64, len(s.allocs)) + for k, v := range s.allocs { + if k != name { + newAllocs[k] = v + } + } + if fmbtV >= 2 { + pc, _, _, _ := runtime.Caller(1) + fmt.Printf("freeFn(%dGB) called from %s\n", size>>30, runtime.FuncForPC(pc).Name()) + } else if fmbtV == 1 { + fmt.Printf("freeFn(%dGB)\n", size>>30) + } + return &LibmemState{freeBytes: s.freeBytes + size, allocs: newAllocs} + } + } + + model := m.NewModel() + + model.From(func(curr m.State) []*m.Transition { + s := curr.(*LibmemState) + var ts []*m.Transition + for _, name := range allocNames { + if _, ok := s.allocs[name]; !ok && s.freeBytes >= allocSizes[name] { + ts = append(ts, m.OnAction("malloc %s", name).WithTest(doMalloc, name).Do(mallocFn(name, allocSizes[name]))...) + } + } + return ts + }) + + model.From(func(curr m.State) []*m.Transition { + s := curr.(*LibmemState) + var ts []*m.Transition + for _, name := range allocNames { + if _, ok := s.allocs[name]; ok { + ts = append(ts, m.OnAction("free %s", name).WithTest(doFree, name).Do(freeFn(name))...) + } + } + return ts + }) + + coverer := m.NewCoverer() + coverer.CoverActionCombinations(3) + + state := m.State(&LibmemState{ + freeBytes: totalAllocBytes, + allocs: map[string]int64{}, + }) + + testStep := 0 + for testStep < maxLibmem2Steps { + path, covStats := coverer.BestPath(model, state, libmem2Search) + if len(path) == 0 { + break + } + for i := 0; i <= covStats.MaxStep; i++ { + testStep++ + step := path[i] + fmt.Printf("step:%d coverage:%d state:%v\n", testStep, coverer.Coverage(), state) + pc, _, _, _ := runtime.Caller(0) + fmt.Printf("%s %s()\n", step.Action(), funcName(pc)) + results := step.Action().Test() + if len(results) > 0 { + if err, _ := results[len(results)-1].(error); err != nil { + t.Errorf("step %d: %s failed: %v", testStep, step.Action(), err) + } + } + state = step.EndState() + coverer.MarkCovered(step) + coverer.UpdateCoverage() + if testStep >= maxLibmem2Steps { + break + } + } + } +}