Repository: cilium/ebpf
Pull request #110 (2020-06-16 14:22:08)

bpf_iter and cgroup support
Use the low-level APIs to construct high-level `Link`s for attaching to cgroups and bpf_iter. Especially the cgroup support is interesting, because it falls back to BPF_PROG_ATTACH after trying bpf_link on older kernel.

New API:

```
 var ErrNotSupported = internal.ErrNotSupported
 func RawAttachProgram(opts RawAttachProgramOptions) error
 func RawDetachProgram(opts RawDetachProgramOptions) error
+type CgroupOptions struct{ ... }
+type Iter struct{ ... }
+    func AttachIter(opts IterOptions) (*Iter, error)
+    func LoadPinnedIter(fileName string) (*Iter, error)
+type IterOptions struct{ ... }
 type Link interface{ ... }
+    func AttachCgroup(opts CgroupOptions) (Link, error)
+    func LoadPinnedCgroup(fileName string) (Link, error)
 type RawAttachProgramOptions struct{ ... }
 type RawDetachProgramOptions struct{ ... }
 type RawLink struct{ ... }
```

lmb: I'm not sure about the `LoadPinned*` methods. I think it might be possible to write a `LoadPinnedLink` function which uses BPF_GET_OBJ_INFO (or whatever that is called) to determine what kind of Link we are dealing with, which would make these obsolete.
lmb: Yes, if we can make them conform to `Link` and if we don't need new dependencies.

Patch:
diff --git a/elf_reader.go b/elf_reader.go
index 0469244..2c0cbc7 100644
--- a/elf_reader.go
+++ b/elf_reader.go
@@ -183,12 +183,13 @@ func (ec *elfCode) loadPrograms(progSections map[elf.SectionIndex]*elf.Section,
 			return nil, xerrors.Errorf("program %s: can't unmarshal instructions: %w", funcSym.Name, err)
 		}
 
-		progType, attachType := getProgType(sec.Name)
+		progType, attachType, attachTo := getProgType(sec.Name)
 
 		spec := &ProgramSpec{
 			Name:          funcSym.Name,
 			Type:          progType,
 			AttachType:    attachType,
+			AttachTo:      attachTo,
 			License:       ec.license,
 			KernelVersion: ec.version,
 			Instructions:  insns,
@@ -567,7 +568,7 @@ func (ec *elfCode) loadDataSections(maps map[string]*MapSpec, dataSections map[e
 	return nil
 }
 
-func getProgType(sectionName string) (ProgramType, AttachType) {
+func getProgType(sectionName string) (ProgramType, AttachType, string) {
 	types := map[string]struct {
 		progType   ProgramType
 		attachType AttachType
@@ -593,6 +594,7 @@ func getProgType(sectionName string) (ProgramType, AttachType) {
 		"sk_msg":                {SkMsg, AttachSkSKBStreamVerdict},
 		"lirc_mode2":            {LircMode2, AttachLircMode2},
 		"flow_dissector":        {FlowDissector, AttachFlowDissector},
+		"iter/":                 {Tracing, AttachTraceIter},
 
 		"cgroup_skb/ingress": {CGroupSKB, AttachCGroupInetIngress},
 		"cgroup_skb/egress":  {CGroupSKB, AttachCGroupInetEgress},
@@ -617,12 +619,18 @@ func getProgType(sectionName string) (ProgramType, AttachType) {
 	}
 
 	for prefix, t := range types {
-		if strings.HasPrefix(sectionName, prefix) {
-			return t.progType, t.attachType
+		if !strings.HasPrefix(sectionName, prefix) {
+			continue
+		}
+
+		if !strings.HasSuffix(prefix, "/") {
+			return t.progType, t.attachType, ""
 		}
+
+		return t.progType, t.attachType, sectionName[len(prefix):]
 	}
 
-	return UnspecifiedProgram, AttachNone
+	return UnspecifiedProgram, AttachNone, ""
 }
 
 func (ec *elfCode) loadRelocations(sections map[elf.SectionIndex]*elf.Section) (map[elf.SectionIndex]map[uint64]elf.Symbol, error) {
diff --git a/internal/fd.go b/internal/fd.go
index f27a1f2..499f1ec 100644
--- a/internal/fd.go
+++ b/internal/fd.go
@@ -1,6 +1,7 @@
 package internal
 
 import (
+	"os"
 	"runtime"
 	"strconv"
 
@@ -61,3 +62,8 @@ func (fd *FD) Dup() (*FD, error) {
 
 	return NewFD(uint32(dup)), nil
 }
+
+func (fd *FD) File(name string) *os.File {
+	fd.Forget()
+	return os.NewFile(uintptr(fd.raw), name)
+}
diff --git a/link/cgroup.go b/link/cgroup.go
new file mode 100644
index 0000000..96a5cec
--- /dev/null
+++ b/link/cgroup.go
@@ -0,0 +1,169 @@
+package link
+
+import (
+	"os"
+
+	"github.com/cilium/ebpf"
+
+	"golang.org/x/xerrors"
+)
+
+type cgroupAttachFlags uint32
+
+// cgroup attach flags
+const (
+	flagAllowOverride cgroupAttachFlags = 1 << iota
+	flagAllowMulti
+	flagReplace
+)
+
+type CgroupOptions struct {
+	// Path to a cgroupv2 folder.
+	Path string
+	// One of the AttachCgroup* constants
+	Attach ebpf.AttachType
+	// Program must be of type CGroup*, and the attach type must match Attach.
+	Program *ebpf.Program
+}
+
+// AttachCgroup links a BPF program to a cgroup.
+func AttachCgroup(opts CgroupOptions) (Link, error) {
+	cgroup, err := os.Open(opts.Path)
+	if err != nil {
+		return nil, xerrors.Errorf("can't open cgroup: %s", err)
+	}
+
+	clone, err := opts.Program.Clone()
+	if err != nil {
+		cgroup.Close()
+		return nil, err
+	}
+
+	var cg Link
+	cg, err = newLinkCgroup(cgroup, opts.Attach, clone)
+	if xerrors.Is(err, ErrNotSupported) {
+		cg, err = newProgAttachCgroup(cgroup, opts.Attach, clone, flagAllowMulti)
+	}
+	if xerrors.Is(err, ErrNotSupported) {
+		cg, err = newProgAttachCgroup(cgroup, opts.Attach, clone, flagAllowOverride)
+	}
+	if err != nil {
+		cgroup.Close()
+		clone.Close()
+		return nil, err
+	}
+
+	return cg, nil
+}
+
+// LoadPinnedCgroup loads a pinned cgroup from a bpffs.
+func LoadPinnedCgroup(fileName string) (Link, error) {
+	link, err := LoadPinnedRawLink(fileName)
+	if err != nil {
+		return nil, err
+	}
+
+	return &linkCgroup{link}, nil
+}
+
+type progAttachCgroup struct {
+	cgroup     *os.File
+	current    *ebpf.Program
+	attachType ebpf.AttachType
+	flags      cgroupAttachFlags
+}
+
+var _ Link = (*progAttachCgroup)(nil)
+
+func (cg *progAttachCgroup) isLink() {}
+
+func newProgAttachCgroup(cgroup *os.File, attach ebpf.AttachType, prog *ebpf.Program, flags cgroupAttachFlags) (*progAttachCgroup, error) {
+	if flags&flagAllowMulti > 0 {
+		if err := haveProgAttachReplace(); err != nil {
+			return nil, xerrors.Errorf("can't support multiple programs: %w", err)
+		}
+	}
+
+	err := RawAttachProgram(RawAttachProgramOptions{
+		Target:  int(cgroup.Fd()),
+		Program: prog,
+		Flags:   uint32(flags),
+		Attach:  attach,
+	})
+	if err != nil {
+		return nil, xerrors.Errorf("cgroup: %w", err)
+	}
+
+	return &progAttachCgroup{cgroup, prog, attach, flags}, nil
+}
+
+func (cg *progAttachCgroup) Close() error {
+	defer cg.cgroup.Close()
+	defer cg.current.Close()
+
+	err := RawDetachProgram(RawDetachProgramOptions{
+		Target:  int(cg.cgroup.Fd()),
+		Program: cg.current,
+		Attach:  cg.attachType,
+	})
+	if err != nil {
+		return xerrors.Errorf("close cgroup: %s", err)
+	}
+	return nil
+}
+
+func (cg *progAttachCgroup) Update(prog *ebpf.Program) error {
+	new, err := prog.Clone()
+	if err != nil {
+		return err
+	}
+
+	args := RawAttachProgramOptions{
+		Target:  int(cg.cgroup.Fd()),
+		Program: prog,
+		Attach:  cg.attachType,
+		Flags:   uint32(cg.flags),
+	}
+
+	if cg.flags&flagAllowMulti > 0 {
+		// Atomically replacing multiple programs requires at least
+		// 5.5 (commit 7dd68b3279f17921 "bpf: Support replacing cgroup-bpf
+		// program in MULTI mode")
+		args.Flags |= uint32(flagReplace)
+		args.Replace = cg.current
+	}
+
+	if err := RawAttachProgram(args); err != nil {
+		new.Close()
+		return xerrors.Errorf("can't update cgroup: %s", err)
+	}
+
+	cg.current.Close()
+	cg.current = new
+	return nil
+}
+
+func (cg *progAttachCgroup) Pin(string) error {
+	return xerrors.Errorf("can't pin cgroup: %w", ErrNotSupported)
+}
+
+type linkCgroup struct {
+	*RawLink
+}
+
+var _ Link = (*linkCgroup)(nil)
+
+func (cg *linkCgroup) isLink() {}
+
+func newLinkCgroup(cgroup *os.File, attach ebpf.AttachType, prog *ebpf.Program) (*linkCgroup, error) {
+	link, err := AttachRawLink(RawLinkOptions{
+		Target:  int(cgroup.Fd()),
+		Program: prog,
+		Attach:  attach,
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return &linkCgroup{link}, err
+}
diff --git a/link/iter.go b/link/iter.go
new file mode 100644
index 0000000..2aa6af1
--- /dev/null
+++ b/link/iter.go
@@ -0,0 +1,86 @@
+package link
+
+import (
+	"io"
+
+	"github.com/cilium/ebpf"
+	"golang.org/x/xerrors"
+)
+
+type IterOptions struct {
+	// Program must be of type Tracing with attach type
+	// AttachTraceIter. The kind of iterator to attach to is
+	// determined at load time via the AttachTo field.
+	//
+	// AttachTo requires the kernel to include BTF of itself,
+	// and it to be compiled with a recent pahole (>= 1.16).
+	Program *ebpf.Program
+}
+
+// AttachIter attaches a BPF seq_file iterator.
+func AttachIter(opts IterOptions) (*Iter, error) {
+	link, err := AttachRawLink(RawLinkOptions{
+		Program: opts.Program,
+		Attach:  ebpf.AttachTraceIter,
+	})
+	if err != nil {
+		return nil, xerrors.Errorf("can't link iterator: %w", err)
+	}
+
+	return &Iter{link}, err
+}
+
+// LoadPinnedIter loads a pinned iterator from a bpffs.
+func LoadPinnedIter(fileName string) (*Iter, error) {
+	link, err := LoadPinnedRawLink(fileName)
+	if err != nil {
+		return nil, err
+	}
+
+	return &Iter{link}, err
+}
+
+// Iter represents an attached bpf_iter.
+type Iter struct {
+	link *RawLink
+}
+
+var _ Link = (*Iter)(nil)
+
+func (it *Iter) isLink() {}
+
+// Close implements Link.
+func (it *Iter) Close() error {
+	return it.link.Close()
+}
+
+// Pin implements Link.
+func (it *Iter) Pin(fileName string) error {
+	return it.link.Pin(fileName)
+}
+
+// Update implements Link.
+func (it *Iter) Update(new *ebpf.Program) error {
+	return it.link.Update(new)
+}
+
+// Open creates a new instance of the iterator.
+//
+// Reading from the returned reader triggers the BPF program.
+func (it *Iter) Open() (io.ReadCloser, error) {
+	linkFd, err := it.link.fd.Value()
+	if err != nil {
+		return nil, err
+	}
+
+	attr := &bpfIterCreateAttr{
+		linkFd: linkFd,
+	}
+
+	fd, err := bpfIterCreate(attr)
+	if err != nil {
+		return nil, xerrors.Errorf("can't create iterator: %w", err)
+	}
+
+	return fd.File("bpf_iter"), nil
+}
diff --git a/link/syscalls.go b/link/syscalls.go
index 0c40750..ac14839 100644
--- a/link/syscalls.go
+++ b/link/syscalls.go
@@ -32,6 +32,45 @@ var haveProgAttach = internal.FeatureTest("BPF_PROG_ATTACH", "4.10", func() (boo
 	return true, nil
 })
 
+var haveProgAttachReplace = internal.FeatureTest("BPF_PROG_ATTACH atomic replacement", "5.5", func() (bool, error) {
+	if err := haveProgAttach(); err != nil {
+		return false, err
+	}
+
+	prog, err := ebpf.NewProgram(&ebpf.ProgramSpec{
+		Type:       ebpf.CGroupSKB,
+		AttachType: ebpf.AttachCGroupInetIngress,
+		License:    "MIT",
+		Instructions: asm.Instructions{
+			asm.Mov.Imm(asm.R0, 0),
+			asm.Return(),
+		},
+	})
+	if err != nil {
+		return false, nil
+	}
+	defer prog.Close()
+
+	// We know that we have BPF_PROG_ATTACH since we can load CGroupSKB programs.
+	// If passing BPF_F_REPLACE gives us EINVAL we know that the feature isn't
+	// present.
+	attr := internal.BPFProgAttachAttr{
+		// We rely on this being checked after attachFlags.
+		TargetFd:    ^uint32(0),
+		AttachBpfFd: uint32(prog.FD()),
+		AttachType:  uint32(ebpf.AttachCGroupInetIngress),
+		AttachFlags: uint32(flagReplace),
+	}
+
+	err = internal.BPFProgAttach(&attr)
+	if xerrors.Is(err, unix.EPERM) {
+		// We don't have enough permissions, so we never get to the point
+		// where flags are checked.
+		return false, err
+	}
+	return !xerrors.Is(err, unix.EINVAL), nil
+})
+
 type bpfLinkCreateAttr struct {
 	progFd     uint32
 	targetFd   uint32
@@ -83,3 +122,16 @@ var haveBPFLink = internal.FeatureTest("bpf_link", "5.7", func() (bool, error) {
 	_, err = bpfLinkCreate(&attr)
 	return !xerrors.Is(err, unix.EINVAL), nil
 })
+
+type bpfIterCreateAttr struct {
+	linkFd uint32
+	flags  uint32
+}
+
+func bpfIterCreate(attr *bpfIterCreateAttr) (*internal.FD, error) {
+	ptr, err := internal.BPF(internal.BPF_ITER_CREATE, unsafe.Pointer(attr), unsafe.Sizeof(*attr))
+	if err == nil {
+		return internal.NewFD(uint32(ptr)), nil
+	}
+	return nil, err
+}
diff --git a/prog.go b/prog.go
index 279992a..fd4430b 100644
--- a/prog.go
+++ b/prog.go
@@ -47,11 +47,24 @@ type ProgramOptions struct {
 type ProgramSpec struct {
 	// Name is passed to the kernel as a debug aid. Must only contain
 	// alpha numeric and '_' characters.
-	Name          string
-	Type          ProgramType
-	AttachType    AttachType
-	Instructions  asm.Instructions
-	License       string
+	Name string
+	// Type determines at which hook in the kernel a program will run.
+	Type       ProgramType
+	AttachType AttachType
+	// Name of a kernel data structure to attach to. It's interpretation
+	// depends on Type and AttachType.
+	AttachTo     string
+	Instructions asm.Instructions
+
+	// License of the program. Some helpers are only available if
+	// the license is deemed compatible with the GPL.
+	//
+	// See https://www.kernel.org/doc/html/latest/process/license-rules.html#id1
+	License string
+
+	// Version used by tracing programs.
+	//
+	// Deprecated: superseded by BTF.
 	KernelVersion uint32
 
 	// The BTF associated with this program. Changing Instructions
@@ -83,9 +96,10 @@ type Program struct {
 	// otherwise it is empty.
 	VerifierLog string
 
-	fd   *internal.FD
-	name string
-	abi  ProgramABI
+	fd         *internal.FD
+	name       string
+	abi        ProgramABI
+	attachType AttachType
 }
 
 // NewProgram creates a new Program.
@@ -236,6 +250,16 @@ func convertProgramSpec(spec *ProgramSpec, handle *btf.Handle) (*bpfProgLoadAttr
 		attr.funcInfo = internal.NewSlicePointer(bytes)
 	}
 
+	if spec.AttachTo != "" {
+		target, err := resolveBTFType(spec.AttachTo, spec.Type, spec.AttachType)
+		if err != nil {
+			return nil, err
+		}
+		if target != nil {
+			attr.attachBTFID = target.ID()
+		}
+	}
+
 	return attr, nil
 }
 
@@ -571,3 +595,29 @@ func (p *Program) ID() (ProgramID, error) {
 	}
 	return ProgramID(info.id), nil
 }
+
+func resolveBTFType(name string, progType ProgramType, attachType AttachType) (btf.Type, error) {
+	kernel, err := btf.LoadKernelSpec()
+	if err != nil {
+		return nil, xerrors.Errorf("can't resolve BTF type %s: %w", name, err)
+	}
+
+	type match struct {
+		p ProgramType
+		a AttachType
+	}
+
+	target := match{progType, attachType}
+	switch target {
+	case match{Tracing, AttachTraceIter}:
+		var target btf.Func
+		if err := kernel.FindType("bpf_iter_"+name, &target); err != nil {
+			return nil, xerrors.Errorf("can't resolve BTF for iterator %s: %w", name, err)
+		}
+
+		return &target, nil
+
+	default:
+		return nil, nil
+	}
+}
diff --git a/syscalls.go b/syscalls.go
index 399501c..ff98cc4 100644
--- a/syscalls.go
+++ b/syscalls.go
@@ -99,6 +99,8 @@ type bpfProgLoadAttr struct {
 	lineInfoRecSize    uint32
 	lineInfo           internal.Pointer
 	lineInfoCnt        uint32
+	attachBTFID        btf.TypeID
+	attachProgFd       uint32
 }
 
 type bpfProgInfo struct {


Test patch:
diff --git a/elf_reader_test.go b/elf_reader_test.go
index 0136dc1..9f81d6b 100644
--- a/elf_reader_test.go
+++ b/elf_reader_test.go
@@ -258,15 +258,17 @@ func TestGetProgType(t *testing.T) {
 		section string
 		pt      ProgramType
 		at      AttachType
+		to      string
 	}{
-		{"socket/garbage", SocketFilter, AttachNone},
-		{"kprobe/func", Kprobe, AttachNone},
-		{"xdp/foo", XDP, AttachNone},
-		{"cgroup_skb/ingress", CGroupSKB, AttachCGroupInetIngress},
+		{"socket/garbage", SocketFilter, AttachNone, ""},
+		{"kprobe/func", Kprobe, AttachNone, "func"},
+		{"xdp/foo", XDP, AttachNone, ""},
+		{"cgroup_skb/ingress", CGroupSKB, AttachCGroupInetIngress, ""},
+		{"iter/bpf_map", Tracing, AttachTraceIter, "bpf_map"},
 	}
 
 	for _, tc := range testcases {
-		pt, at := getProgType(tc.section)
+		pt, at, to := getProgType(tc.section)
 		if pt != tc.pt {
 			t.Errorf("section %s: expected type %s, got %s", tc.section, tc.pt, pt)
 		}
@@ -274,5 +276,9 @@ func TestGetProgType(t *testing.T) {
 		if at != tc.at {
 			t.Errorf("section %s: expected attach type %s, got %s", tc.section, tc.at, at)
 		}
+
+		if to != tc.to {
+			t.Errorf("section %s: expected attachment to be %q, got %q", tc.section, tc.to, to)
+		}
 	}
 }
diff --git a/link/cgroup_test.go b/link/cgroup_test.go
new file mode 100644
index 0000000..d9358a4
--- /dev/null
+++ b/link/cgroup_test.go
@@ -0,0 +1,81 @@
+package link
+
+import (
+	"testing"
+
+	"github.com/cilium/ebpf"
+	"github.com/cilium/ebpf/internal/testutils"
+)
+
+func TestAttachCgroup(t *testing.T) {
+	cgroup, prog, cleanup := mustCgroupFixtures(t)
+	defer cleanup()
+
+	link, err := AttachCgroup(CgroupOptions{
+		Path:    cgroup.Name(),
+		Attach:  ebpf.AttachCGroupInetEgress,
+		Program: prog,
+	})
+	testutils.SkipIfNotSupported(t, err)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if haveBPFLink() == nil {
+		if _, ok := link.(*linkCgroup); !ok {
+			t.Fatalf("Have support for bpf_link, but got %T instead of linkCgroup", link)
+		}
+	} else {
+		if _, ok := link.(*progAttachCgroup); !ok {
+			t.Fatalf("Expected progAttachCgroup, got %T instead", link)
+		}
+	}
+}
+
+func TestProgAttachCgroup(t *testing.T) {
+	cgroup, prog, cleanup := mustCgroupFixtures(t)
+	defer cleanup()
+
+	link, err := newProgAttachCgroup(cgroup, ebpf.AttachCGroupInetEgress, prog, 0)
+	if err != nil {
+		t.Fatal("Can't create link:", err)
+	}
+
+	testLink(t, link, testLinkOptions{
+		prog: prog,
+	})
+}
+
+func TestProgAttachCgroupAllowMulti(t *testing.T) {
+	cgroup, prog, cleanup := mustCgroupFixtures(t)
+	defer cleanup()
+
+	link, err := newProgAttachCgroup(cgroup, ebpf.AttachCGroupInetEgress, prog, flagAllowMulti)
+	testutils.SkipIfNotSupported(t, err)
+	if err != nil {
+		t.Fatal("Can't create link:", err)
+	}
+
+	// It's currently not possible for a program to replace
+	// itself.
+	prog2 := mustCgroupEgressProgram(t)
+	testLink(t, link, testLinkOptions{
+		prog: prog2,
+	})
+}
+
+func TestLinkCgroup(t *testing.T) {
+	cgroup, prog, cleanup := mustCgroupFixtures(t)
+	defer cleanup()
+
+	link, err := newLinkCgroup(cgroup, ebpf.AttachCGroupInetEgress, prog)
+	testutils.SkipIfNotSupported(t, err)
+	if err != nil {
+		t.Fatal("Can't create link:", err)
+	}
+
+	testLink(t, link, testLinkOptions{
+		prog:       prog,
+		loadPinned: LoadPinnedCgroup,
+	})
+}
diff --git a/link/iter_test.go b/link/iter_test.go
new file mode 100644
index 0000000..5e1bac0
--- /dev/null
+++ b/link/iter_test.go
@@ -0,0 +1,61 @@
+package link
+
+import (
+	"io/ioutil"
+	"testing"
+
+	"github.com/cilium/ebpf"
+	"github.com/cilium/ebpf/asm"
+	"github.com/cilium/ebpf/internal/btf"
+
+	"golang.org/x/xerrors"
+)
+
+func TestIter(t *testing.T) {
+	prog, err := ebpf.NewProgram(&ebpf.ProgramSpec{
+		Type:       ebpf.Tracing,
+		AttachType: ebpf.AttachTraceIter,
+		AttachTo:   "bpf_map",
+		Instructions: asm.Instructions{
+			asm.Mov.Imm(asm.R0, 0),
+			asm.Return(),
+		},
+		License: "MIT",
+	})
+	if xerrors.Is(err, btf.ErrNotFound) {
+		t.Skip("Kernel doesn't support iter:", err)
+	}
+	if err != nil {
+		t.Fatal("Can't load program:", err)
+	}
+	defer prog.Close()
+
+	it, err := AttachIter(IterOptions{
+		Program: prog,
+	})
+	if err != nil {
+		t.Fatal("Can't create iter:", err)
+	}
+
+	file, err := it.Open()
+	if err != nil {
+		t.Fatal("Can't open iter instance:", err)
+	}
+	defer file.Close()
+
+	contents, err := ioutil.ReadAll(file)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(contents) != 0 {
+		t.Error("Non-empty output from no-op iterator:", string(contents))
+	}
+
+	testLink(t, it, testLinkOptions{
+		prog: prog,
+		loadPinned: func(s string) (Link, error) {
+			return LoadPinnedIter(s)
+		},
+	})
+}
diff --git a/link/syscalls_test.go b/link/syscalls_test.go
index aabad55..361dc47 100644
--- a/link/syscalls_test.go
+++ b/link/syscalls_test.go
@@ -10,6 +10,10 @@ func TestHaveProgAttach(t *testing.T) {
 	testutils.CheckFeatureTest(t, haveProgAttach)
 }
 
+func TestHaveProgAttachReplace(t *testing.T) {
+	testutils.CheckFeatureTest(t, haveProgAttachReplace)
+}
+
 func TestHaveBPFLink(t *testing.T) {
 	testutils.CheckFeatureTest(t, haveBPFLink)
 }