diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-21 19:28:23 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-21 19:28:23 +0200 |
| commit | a5b711c5f221704209706b79fbf310a18e079391 (patch) | |
| tree | 84615902f79a901aa9d98e3423c4756477b7cf4b /integrationtests | |
| parent | 2c2cbe07f5e10fdb996e2a039cde84be44866f18 (diff) | |
more on integration tests
Diffstat (limited to 'integrationtests')
| -rw-r--r-- | integrationtests/close_test.go | 25 | ||||
| -rw-r--r-- | integrationtests/cmd/ioworkload/scenarios.go | 1122 | ||||
| -rw-r--r-- | integrationtests/dir_test.go | 47 | ||||
| -rw-r--r-- | integrationtests/dup_test.go | 36 | ||||
| -rw-r--r-- | integrationtests/fcntl_test.go | 36 | ||||
| -rw-r--r-- | integrationtests/harness.go | 179 | ||||
| -rw-r--r-- | integrationtests/helpers_test.go | 50 | ||||
| -rw-r--r-- | integrationtests/link_test.go | 59 | ||||
| -rw-r--r-- | integrationtests/open_test.go | 36 | ||||
| -rw-r--r-- | integrationtests/readwrite_test.go | 64 | ||||
| -rw-r--r-- | integrationtests/rename_test.go | 36 | ||||
| -rw-r--r-- | integrationtests/stat_test.go | 80 | ||||
| -rw-r--r-- | integrationtests/unlink_test.go | 36 |
13 files changed, 1782 insertions, 24 deletions
diff --git a/integrationtests/close_test.go b/integrationtests/close_test.go new file mode 100644 index 0000000..d91a37d --- /dev/null +++ b/integrationtests/close_test.go @@ -0,0 +1,25 @@ +package integrationtests + +import "testing" + +func TestCloseBasic(t *testing.T) { + runScenario(t, "close-basic", []ExpectedEvent{ + { + PathContains: "closefile-", + Tracepoint: "enter_close", + Comm: "ioworkload", + MinCount: 3, + }, + }) +} + +func TestCloseRange(t *testing.T) { + runScenario(t, "close-range", []ExpectedEvent{ + { + PathContains: "closerangefile-", + Tracepoint: "enter_close_range", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} diff --git a/integrationtests/cmd/ioworkload/scenarios.go b/integrationtests/cmd/ioworkload/scenarios.go index 41563d8..bf1561c 100644 --- a/integrationtests/cmd/ioworkload/scenarios.go +++ b/integrationtests/cmd/ioworkload/scenarios.go @@ -4,22 +4,50 @@ import ( "fmt" "os" "path/filepath" + "runtime" "syscall" + "unsafe" ) // scenarios maps scenario names to their execution functions. var scenarios = map[string]func() error{ - "open-basic": openBasic, - "open-creat": openCreat, - "readwrite-basic": readwriteBasic, + "open-basic": openBasic, + "open-creat": openCreat, + "open-by-handle-at": openByHandleAt, + "readwrite-basic": readwriteBasic, + "readwrite-pread": readwritePread, + "readwrite-pwrite": readwritePwrite, + "readwrite-readv": readwriteReadv, + "readwrite-writev": readwriteWritev, "close-basic": closeBasic, + "close-range": closeRange, "dup-basic": dupBasic, - "fcntl-dupfd": fcntlDupfd, - "rename-basic": renameBasic, + "dup-dup2": dupDup2, + "dup-dup3": dupDup3, + "fcntl-dupfd": fcntlDupfd, + "fcntl-setfl": fcntlSetfl, + "fcntl-dupfd-cloexec": fcntlDupfdCloexec, + "rename-basic": renameBasic, + "rename-renameat": renameRenameat, + "rename-renameat2": renameRenameat2, "link-basic": linkBasic, + "link-linkat": linkLinkat, + "link-symlinkat": linkSymlinkat, + "link-readlinkat": linkReadlinkat, "unlink-basic": unlinkBasic, + "unlink-unlinkat": unlinkUnlinkat, + "unlink-rmdir": unlinkRmdir, "dir-basic": dirBasic, + "dir-mkdirat": dirMkdirat, + "dir-chdir": dirChdir, + "dir-getdents": dirGetdents, "stat-basic": statBasic, + "stat-fstat": statFstat, + "stat-lstat": statLstat, + "stat-newfstatat": statNewfstatat, + "stat-statx": statStatx, + "stat-access": statAccess, + "stat-faccessat": statFaccessat, "sync-basic": syncBasic, "truncate-basic": truncateBasic, } @@ -95,6 +123,122 @@ func readwriteBasic() error { return nil } +// readwritePread opens a file, writes data, then reads it back via pread64. +func readwritePread() error { + dir, cleanup, err := makeTempDir("readwrite-pread") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "preadfile.txt") + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + defer syscall.Close(fd) + + data := []byte("pread test data") + if _, err := syscall.Write(fd, data); err != nil { + return fmt.Errorf("write: %w", err) + } + + buf := make([]byte, len(data)) + if _, err := syscall.Pread(fd, buf, 0); err != nil { + return fmt.Errorf("pread: %w", err) + } + return nil +} + +// readwritePwrite opens a file and writes data via pwrite64. +func readwritePwrite() error { + dir, cleanup, err := makeTempDir("readwrite-pwrite") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "pwritefile.txt") + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + defer syscall.Close(fd) + + if _, err := syscall.Pwrite(fd, []byte("pwrite test data"), 0); err != nil { + return fmt.Errorf("pwrite: %w", err) + } + return nil +} + +// readwriteReadv opens a file, writes data, then reads it back via readv. +func readwriteReadv() error { + dir, cleanup, err := makeTempDir("readwrite-readv") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "readvfile.txt") + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + defer syscall.Close(fd) + + data := []byte("readv test data here") + if _, err := syscall.Write(fd, data); err != nil { + return fmt.Errorf("write: %w", err) + } + if _, err := syscall.Seek(fd, 0, 0); err != nil { + return fmt.Errorf("seek: %w", err) + } + + buf1 := make([]byte, 5) + buf2 := make([]byte, 15) + iovs := []syscall.Iovec{ + {Base: &buf1[0], Len: uint64(len(buf1))}, + {Base: &buf2[0], Len: uint64(len(buf2))}, + } + _, _, errno := syscall.Syscall(syscall.SYS_READV, uintptr(fd), uintptr(unsafe.Pointer(&iovs[0])), uintptr(len(iovs))) + runtime.KeepAlive(buf1) + runtime.KeepAlive(buf2) + if errno != 0 { + return fmt.Errorf("readv: %w", errno) + } + return nil +} + +// readwriteWritev opens a file and writes data via writev. +func readwriteWritev() error { + dir, cleanup, err := makeTempDir("readwrite-writev") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "writevfile.txt") + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + defer syscall.Close(fd) + + buf1 := []byte("writev ") + buf2 := []byte("test data") + iovs := []syscall.Iovec{ + {Base: &buf1[0], Len: uint64(len(buf1))}, + {Base: &buf2[0], Len: uint64(len(buf2))}, + } + _, _, errno := syscall.Syscall(syscall.SYS_WRITEV, uintptr(fd), uintptr(unsafe.Pointer(&iovs[0])), uintptr(len(iovs))) + runtime.KeepAlive(buf1) + runtime.KeepAlive(buf2) + if errno != 0 { + return fmt.Errorf("writev: %w", errno) + } + return nil +} + // closeBasic opens multiple files and closes them. func closeBasic() error { dir, cleanup, err := makeTempDir("close-basic") @@ -120,6 +264,39 @@ func closeBasic() error { return nil } +// closeRange opens multiple files and closes a range of them via close_range(2). +func closeRange() error { + dir, cleanup, err := makeTempDir("close-range") + if err != nil { + return err + } + defer cleanup() + + var fds []int + for i := range 3 { + path := filepath.Join(dir, fmt.Sprintf("closerangefile-%d.txt", i)) + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open %d: %w", i, err) + } + fds = append(fds, fd) + } + + if fds[2]-fds[0] != 2 { + return fmt.Errorf("fds not contiguous: %v", fds) + } + + first := uintptr(fds[0]) + last := uintptr(fds[len(fds)-1]) + _, _, errno := syscall.Syscall(sysCloseRange, first, last, 0) + if errno != 0 { + return fmt.Errorf("close_range: %w", errno) + } + return nil +} + +const sysCloseRange = 436 + // dupBasic opens a file, dups the fd, writes via the dup, closes both. func dupBasic() error { dir, cleanup, err := makeTempDir("dup-basic") @@ -147,6 +324,63 @@ func dupBasic() error { return nil } +// dupDup2 opens a file and duplicates the fd onto a specific target fd via dup2. +func dupDup2() error { + dir, cleanup, err := makeTempDir("dup-dup2") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "dup2file.txt") + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + defer syscall.Close(fd) + + // Use a high fd number to avoid collisions. + targetFd := 500 + if err := syscall.Dup2(fd, targetFd); err != nil { + return fmt.Errorf("dup2: %w", err) + } + defer syscall.Close(targetFd) + + if _, err := syscall.Write(targetFd, []byte("via dup2")); err != nil { + return fmt.Errorf("write via dup2: %w", err) + } + return nil +} + +// dupDup3 opens a file and duplicates the fd onto a specific target fd via dup3 +// with O_CLOEXEC flag. +func dupDup3() error { + dir, cleanup, err := makeTempDir("dup-dup3") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "dup3file.txt") + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + defer syscall.Close(fd) + + // Use a high fd number to avoid collisions. + targetFd := 501 + if err := syscall.Dup3(fd, targetFd, syscall.O_CLOEXEC); err != nil { + return fmt.Errorf("dup3: %w", err) + } + defer syscall.Close(targetFd) + + if _, err := syscall.Write(targetFd, []byte("via dup3")); err != nil { + return fmt.Errorf("write via dup3: %w", err) + } + return nil +} + // fcntlDupfd uses fcntl F_DUPFD to duplicate a file descriptor. func fcntlDupfd() error { dir, cleanup, err := makeTempDir("fcntl-dupfd") @@ -174,7 +408,67 @@ func fcntlDupfd() error { return nil } -// renameBasic creates a file and renames it. +// fcntlSetfl uses fcntl F_GETFL/F_SETFL to read and modify file status flags. +func fcntlSetfl() error { + dir, cleanup, err := makeTempDir("fcntl-setfl") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "fcntlsetflfile.txt") + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + defer syscall.Close(fd) + + flags, _, errno := syscall.Syscall(syscall.SYS_FCNTL, uintptr(fd), syscall.F_GETFL, 0) + if errno != 0 { + return fmt.Errorf("fcntl F_GETFL: %w", errno) + } + + _, _, errno = syscall.Syscall(syscall.SYS_FCNTL, uintptr(fd), syscall.F_SETFL, flags|syscall.O_APPEND) + if errno != 0 { + return fmt.Errorf("fcntl F_SETFL: %w", errno) + } + + if _, err := syscall.Write(fd, []byte("appended via fcntl setfl")); err != nil { + return fmt.Errorf("write: %w", err) + } + return nil +} + +// fcntlDupfdCloexec uses fcntl F_DUPFD_CLOEXEC to duplicate a file descriptor +// with the close-on-exec flag set. +func fcntlDupfdCloexec() error { + dir, cleanup, err := makeTempDir("fcntl-dupfd-cloexec") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "fcntlcloexecfile.txt") + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + defer syscall.Close(fd) + + newFd, _, errno := syscall.Syscall(syscall.SYS_FCNTL, uintptr(fd), syscall.F_DUPFD_CLOEXEC, 0) + if errno != 0 { + return fmt.Errorf("fcntl F_DUPFD_CLOEXEC: %w", errno) + } + defer syscall.Close(int(newFd)) + + if _, err := syscall.Write(int(newFd), []byte("via fcntl dupfd cloexec")); err != nil { + return fmt.Errorf("write via fcntl dup cloexec: %w", err) + } + return nil +} + +// renameBasic creates a file and renames it via rename(2). +// Uses raw SYS_RENAME because Go's syscall.Rename wraps renameat on amd64. func renameBasic() error { dir, cleanup, err := makeTempDir("rename-basic") if err != nil { @@ -193,10 +487,138 @@ func renameBasic() error { return fmt.Errorf("close: %w", err) } - return syscall.Rename(oldPath, newPath) + oldBytes, err := syscall.BytePtrFromString(oldPath) + if err != nil { + return fmt.Errorf("old path bytes: %w", err) + } + newBytes, err := syscall.BytePtrFromString(newPath) + if err != nil { + return fmt.Errorf("new path bytes: %w", err) + } + + _, _, errno := syscall.Syscall( + syscall.SYS_RENAME, + uintptr(unsafe.Pointer(oldBytes)), + uintptr(unsafe.Pointer(newBytes)), + 0, + ) + runtime.KeepAlive(oldBytes) + runtime.KeepAlive(newBytes) + if errno != 0 { + return fmt.Errorf("rename: %w", errno) + } + return nil +} + +// renameRenameat creates a file and renames it via renameat(2). +func renameRenameat() error { + dir, cleanup, err := makeTempDir("rename-renameat") + if err != nil { + return err + } + defer cleanup() + + oldName := "renameat-old.txt" + newName := "renameat-new.txt" + path := filepath.Join(dir, oldName) + + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + if err := syscall.Close(fd); err != nil { + return fmt.Errorf("close: %w", err) + } + + dirFD, err := syscall.Open(dir, syscall.O_RDONLY|syscall.O_DIRECTORY, 0) + if err != nil { + return fmt.Errorf("open dir: %w", err) + } + defer syscall.Close(dirFD) + + oldBytes, err := syscall.BytePtrFromString(oldName) + if err != nil { + return fmt.Errorf("old name bytes: %w", err) + } + newBytes, err := syscall.BytePtrFromString(newName) + if err != nil { + return fmt.Errorf("new name bytes: %w", err) + } + + _, _, errno := syscall.Syscall6( + syscall.SYS_RENAMEAT, + uintptr(dirFD), + uintptr(unsafe.Pointer(oldBytes)), + uintptr(dirFD), + uintptr(unsafe.Pointer(newBytes)), + 0, 0, + ) + runtime.KeepAlive(oldBytes) + runtime.KeepAlive(newBytes) + if errno != 0 { + return fmt.Errorf("renameat: %w", errno) + } + return nil +} + +const sysRenameat2 = 316 // SYS_RENAMEAT2 on amd64 + +// renameRenameat2 creates a file and renames it via renameat2(2) with no flags. +func renameRenameat2() error { + dir, cleanup, err := makeTempDir("rename-renameat2") + if err != nil { + return err + } + defer cleanup() + + oldName := "renameat2-old.txt" + newName := "renameat2-new.txt" + path := filepath.Join(dir, oldName) + + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + if err := syscall.Close(fd); err != nil { + return fmt.Errorf("close: %w", err) + } + + dirFD, err := syscall.Open(dir, syscall.O_RDONLY|syscall.O_DIRECTORY, 0) + if err != nil { + return fmt.Errorf("open dir: %w", err) + } + defer syscall.Close(dirFD) + + oldBytes, err := syscall.BytePtrFromString(oldName) + if err != nil { + return fmt.Errorf("old name bytes: %w", err) + } + newBytes, err := syscall.BytePtrFromString(newName) + if err != nil { + return fmt.Errorf("new name bytes: %w", err) + } + + _, _, errno := syscall.Syscall6( + sysRenameat2, + uintptr(dirFD), + uintptr(unsafe.Pointer(oldBytes)), + uintptr(dirFD), + uintptr(unsafe.Pointer(newBytes)), + 0, // flags=0: plain rename + 0, + ) + runtime.KeepAlive(oldBytes) + runtime.KeepAlive(newBytes) + if errno != 0 { + return fmt.Errorf("renameat2: %w", errno) + } + return nil } -// linkBasic creates a file, hard links it, and symlinks it. +// linkBasic creates a file, hard links it via link(2), symlinks it via +// symlink(2), and reads the symlink via readlink(2). +// Uses raw SYS_LINK, SYS_SYMLINK, SYS_READLINK because Go's syscall wrappers +// delegate to linkat/symlinkat/readlinkat on amd64. func linkBasic() error { dir, cleanup, err := makeTempDir("link-basic") if err != nil { @@ -213,24 +635,238 @@ func linkBasic() error { return fmt.Errorf("close: %w", err) } + // Hard link via raw SYS_LINK. hardPath := filepath.Join(dir, "hardlink.txt") - if err := syscall.Link(origPath, hardPath); err != nil { - return fmt.Errorf("link: %w", err) + origBytes, err := syscall.BytePtrFromString(origPath) + if err != nil { + return fmt.Errorf("orig path bytes: %w", err) + } + hardBytes, err := syscall.BytePtrFromString(hardPath) + if err != nil { + return fmt.Errorf("hard path bytes: %w", err) + } + _, _, errno := syscall.Syscall( + syscall.SYS_LINK, + uintptr(unsafe.Pointer(origBytes)), + uintptr(unsafe.Pointer(hardBytes)), + 0, + ) + runtime.KeepAlive(origBytes) + runtime.KeepAlive(hardBytes) + if errno != 0 { + return fmt.Errorf("link: %w", errno) } + // Symlink via raw SYS_SYMLINK. symPath := filepath.Join(dir, "symlink.txt") - if err := syscall.Symlink(origPath, symPath); err != nil { - return fmt.Errorf("symlink: %w", err) + targetBytes, err := syscall.BytePtrFromString(origPath) + if err != nil { + return fmt.Errorf("target path bytes: %w", err) + } + symBytes, err := syscall.BytePtrFromString(symPath) + if err != nil { + return fmt.Errorf("sym path bytes: %w", err) + } + _, _, errno = syscall.Syscall( + syscall.SYS_SYMLINK, + uintptr(unsafe.Pointer(targetBytes)), + uintptr(unsafe.Pointer(symBytes)), + 0, + ) + runtime.KeepAlive(targetBytes) + runtime.KeepAlive(symBytes) + if errno != 0 { + return fmt.Errorf("symlink: %w", errno) + } + + // Readlink via raw SYS_READLINK. + symBytes2, err := syscall.BytePtrFromString(symPath) + if err != nil { + return fmt.Errorf("sym path bytes: %w", err) + } + buf := make([]byte, 256) + _, _, errno = syscall.Syscall( + syscall.SYS_READLINK, + uintptr(unsafe.Pointer(symBytes2)), + uintptr(unsafe.Pointer(&buf[0])), + uintptr(len(buf)), + ) + runtime.KeepAlive(symBytes2) + runtime.KeepAlive(buf) + if errno != 0 { + return fmt.Errorf("readlink: %w", errno) + } + return nil +} + +// linkLinkat creates a file and hard links it via linkat(2). +func linkLinkat() error { + dir, cleanup, err := makeTempDir("link-linkat") + if err != nil { + return err + } + defer cleanup() + + origName := "linkat-original.txt" + origPath := filepath.Join(dir, origName) + fd, err := syscall.Open(origPath, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + if err := syscall.Close(fd); err != nil { + return fmt.Errorf("close: %w", err) + } + + dirFD, err := syscall.Open(dir, syscall.O_RDONLY|syscall.O_DIRECTORY, 0) + if err != nil { + return fmt.Errorf("open dir: %w", err) + } + defer syscall.Close(dirFD) + + hardName := "linkat-hard.txt" + oldBytes, err := syscall.BytePtrFromString(origName) + if err != nil { + return fmt.Errorf("old name bytes: %w", err) + } + newBytes, err := syscall.BytePtrFromString(hardName) + if err != nil { + return fmt.Errorf("new name bytes: %w", err) + } + + _, _, errno := syscall.Syscall6( + syscall.SYS_LINKAT, + uintptr(dirFD), + uintptr(unsafe.Pointer(oldBytes)), + uintptr(dirFD), + uintptr(unsafe.Pointer(newBytes)), + 0, // flags + 0, + ) + runtime.KeepAlive(oldBytes) + runtime.KeepAlive(newBytes) + if errno != 0 { + return fmt.Errorf("linkat: %w", errno) + } + return nil +} + +// linkSymlinkat creates a symlink via symlinkat(2). +func linkSymlinkat() error { + dir, cleanup, err := makeTempDir("link-symlinkat") + if err != nil { + return err + } + defer cleanup() + + origPath := filepath.Join(dir, "symlinkat-original.txt") + fd, err := syscall.Open(origPath, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + if err := syscall.Close(fd); err != nil { + return fmt.Errorf("close: %w", err) + } + + dirFD, err := syscall.Open(dir, syscall.O_RDONLY|syscall.O_DIRECTORY, 0) + if err != nil { + return fmt.Errorf("open dir: %w", err) + } + defer syscall.Close(dirFD) + + targetBytes, err := syscall.BytePtrFromString(origPath) + if err != nil { + return fmt.Errorf("target bytes: %w", err) + } + linkName := "symlinkat-link.txt" + linkBytes, err := syscall.BytePtrFromString(linkName) + if err != nil { + return fmt.Errorf("link name bytes: %w", err) + } + + _, _, errno := syscall.Syscall( + syscall.SYS_SYMLINKAT, + uintptr(unsafe.Pointer(targetBytes)), + uintptr(dirFD), + uintptr(unsafe.Pointer(linkBytes)), + ) + runtime.KeepAlive(targetBytes) + runtime.KeepAlive(linkBytes) + if errno != 0 { + return fmt.Errorf("symlinkat: %w", errno) + } + return nil +} + +// linkReadlinkat creates a symlink, then reads it via readlinkat(2). +func linkReadlinkat() error { + dir, cleanup, err := makeTempDir("link-readlinkat") + if err != nil { + return err + } + defer cleanup() + + origPath := filepath.Join(dir, "readlinkat-original.txt") + fd, err := syscall.Open(origPath, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + if err := syscall.Close(fd); err != nil { + return fmt.Errorf("close: %w", err) + } + + // Create symlink using raw SYS_SYMLINK so we don't mix tracepoints. + linkPath := filepath.Join(dir, "readlinkat-link.txt") + targetBytes, err := syscall.BytePtrFromString(origPath) + if err != nil { + return fmt.Errorf("target bytes: %w", err) + } + linkPathBytes, err := syscall.BytePtrFromString(linkPath) + if err != nil { + return fmt.Errorf("link path bytes: %w", err) + } + _, _, errno := syscall.Syscall( + syscall.SYS_SYMLINK, + uintptr(unsafe.Pointer(targetBytes)), + uintptr(unsafe.Pointer(linkPathBytes)), + 0, + ) + runtime.KeepAlive(targetBytes) + runtime.KeepAlive(linkPathBytes) + if errno != 0 { + return fmt.Errorf("symlink: %w", errno) } + // Read via readlinkat(2). + dirFD, err := syscall.Open(dir, syscall.O_RDONLY|syscall.O_DIRECTORY, 0) + if err != nil { + return fmt.Errorf("open dir: %w", err) + } + defer syscall.Close(dirFD) + + linkName := "readlinkat-link.txt" + nameBytes, err := syscall.BytePtrFromString(linkName) + if err != nil { + return fmt.Errorf("link name bytes: %w", err) + } buf := make([]byte, 256) - if _, err := syscall.Readlink(symPath, buf); err != nil { - return fmt.Errorf("readlink: %w", err) + _, _, errno = syscall.Syscall6( + syscall.SYS_READLINKAT, + uintptr(dirFD), + uintptr(unsafe.Pointer(nameBytes)), + uintptr(unsafe.Pointer(&buf[0])), + uintptr(len(buf)), + 0, 0, + ) + runtime.KeepAlive(nameBytes) + runtime.KeepAlive(buf) + if errno != 0 { + return fmt.Errorf("readlinkat: %w", errno) } return nil } -// unlinkBasic creates a file and unlinks it. +// unlinkBasic creates a file and unlinks it via raw SYS_UNLINK. +// We use the raw syscall because Go's syscall.Unlink wraps unlinkat on amd64. func unlinkBasic() error { dir, cleanup, err := makeTempDir("unlink-basic") if err != nil { @@ -247,10 +883,82 @@ func unlinkBasic() error { return fmt.Errorf("close: %w", err) } - return syscall.Unlink(path) + pathBytes, err := syscall.BytePtrFromString(path) + if err != nil { + return fmt.Errorf("path bytes: %w", err) + } + _, _, errno := syscall.Syscall(syscall.SYS_UNLINK, uintptr(unsafe.Pointer(pathBytes)), 0, 0) + runtime.KeepAlive(pathBytes) + if errno != 0 { + return fmt.Errorf("unlink: %w", errno) + } + return nil } -// dirBasic creates a directory, checks access, then removes it. +// unlinkUnlinkat creates a file and unlinks it via unlinkat(2). +func unlinkUnlinkat() error { + dir, cleanup, err := makeTempDir("unlink-unlinkat") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "unlinkat-file.txt") + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + if err := syscall.Close(fd); err != nil { + return fmt.Errorf("close: %w", err) + } + + dirFD, err := syscall.Open(dir, syscall.O_RDONLY|syscall.O_DIRECTORY, 0) + if err != nil { + return fmt.Errorf("open dir: %w", err) + } + defer syscall.Close(dirFD) + + nameBytes, err := syscall.BytePtrFromString("unlinkat-file.txt") + if err != nil { + return fmt.Errorf("name bytes: %w", err) + } + _, _, errno := syscall.Syscall(syscall.SYS_UNLINKAT, uintptr(dirFD), uintptr(unsafe.Pointer(nameBytes)), 0) + runtime.KeepAlive(nameBytes) + if errno != 0 { + return fmt.Errorf("unlinkat: %w", errno) + } + return nil +} + +// unlinkRmdir creates a directory and removes it via raw SYS_RMDIR. +// We use the raw syscall because Go's syscall.Rmdir wraps unlinkat on amd64. +func unlinkRmdir() error { + dir, cleanup, err := makeTempDir("unlink-rmdir") + if err != nil { + return err + } + defer cleanup() + + subDir := filepath.Join(dir, "rmdir-me") + if err := syscall.Mkdir(subDir, 0o755); err != nil { + return fmt.Errorf("mkdir: %w", err) + } + + pathBytes, err := syscall.BytePtrFromString(subDir) + if err != nil { + return fmt.Errorf("path bytes: %w", err) + } + _, _, errno := syscall.Syscall(syscall.SYS_RMDIR, uintptr(unsafe.Pointer(pathBytes)), 0, 0) + runtime.KeepAlive(pathBytes) + if errno != 0 { + return fmt.Errorf("rmdir: %w", errno) + } + return nil +} + +// dirBasic creates a directory via raw SYS_MKDIR, checks access, then removes it +// via raw SYS_RMDIR. We use raw syscalls because Go's syscall.Mkdir wraps mkdirat +// and syscall.Rmdir wraps unlinkat on amd64. func dirBasic() error { dir, cleanup, err := makeTempDir("dir-basic") if err != nil { @@ -259,16 +967,102 @@ func dirBasic() error { defer cleanup() subDir := filepath.Join(dir, "subdir") - if err := syscall.Mkdir(subDir, 0o755); err != nil { - return fmt.Errorf("mkdir: %w", err) + pathBytes, err := syscall.BytePtrFromString(subDir) + if err != nil { + return fmt.Errorf("path bytes: %w", err) } + _, _, errno := syscall.Syscall(syscall.SYS_MKDIR, uintptr(unsafe.Pointer(pathBytes)), 0o755, 0) + runtime.KeepAlive(pathBytes) + if errno != 0 { + return fmt.Errorf("mkdir: %w", errno) + } + if err := syscall.Access(subDir, syscall.F_OK); err != nil { return fmt.Errorf("access: %w", err) } - return syscall.Rmdir(subDir) + + pathBytes2, err := syscall.BytePtrFromString(subDir) + if err != nil { + return fmt.Errorf("path bytes: %w", err) + } + _, _, errno = syscall.Syscall(syscall.SYS_RMDIR, uintptr(unsafe.Pointer(pathBytes2)), 0, 0) + runtime.KeepAlive(pathBytes2) + if errno != 0 { + return fmt.Errorf("rmdir: %w", errno) + } + return nil } -// statBasic creates a file and stats it. +// dirMkdirat creates a directory via mkdirat(2) using Go's syscall.Mkdir +// which wraps mkdirat with AT_FDCWD on amd64. +func dirMkdirat() error { + dir, cleanup, err := makeTempDir("dir-mkdirat") + if err != nil { + return err + } + defer cleanup() + + subDir := filepath.Join(dir, "mkdirat-subdir") + if err := syscall.Mkdir(subDir, 0o755); err != nil { + return fmt.Errorf("mkdirat: %w", err) + } + return nil +} + +// dirChdir creates a temp directory, then changes to it via chdir(2). +// Restores the original working directory afterward. +func dirChdir() error { + origDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("getwd: %w", err) + } + + dir, cleanup, err := makeTempDir("dir-chdir") + if err != nil { + return err + } + defer cleanup() + defer syscall.Chdir(origDir) + + if err := syscall.Chdir(dir); err != nil { + return fmt.Errorf("chdir: %w", err) + } + return nil +} + +// dirGetdents opens a directory and reads its entries via getdents64(2). +func dirGetdents() error { + dir, cleanup, err := makeTempDir("dir-getdents") + if err != nil { + return err + } + defer cleanup() + + // Create a file so getdents has something to return. + filePath := filepath.Join(dir, "getdents-file.txt") + fd, err := syscall.Open(filePath, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open file: %w", err) + } + syscall.Close(fd) + + dirFD, err := syscall.Open(dir, syscall.O_RDONLY|syscall.O_DIRECTORY, 0) + if err != nil { + return fmt.Errorf("open dir: %w", err) + } + defer syscall.Close(dirFD) + + buf := make([]byte, 4096) + _, _, errno := syscall.Syscall(syscall.SYS_GETDENTS64, uintptr(dirFD), uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf))) + runtime.KeepAlive(buf) + if errno != 0 { + return fmt.Errorf("getdents64: %w", errno) + } + return nil +} + +// statBasic creates a file and stats it via raw SYS_STAT (newstat). +// We use the raw syscall because Go's syscall.Stat wraps newfstatat on amd64. func statBasic() error { dir, cleanup, err := makeTempDir("stat-basic") if err != nil { @@ -281,14 +1075,191 @@ func statBasic() error { if err != nil { return fmt.Errorf("open: %w", err) } + syscall.Close(fd) + + var stat syscall.Stat_t + pathBytes, err := syscall.BytePtrFromString(path) + if err != nil { + return fmt.Errorf("path bytes: %w", err) + } + _, _, errno := syscall.Syscall(syscall.SYS_STAT, uintptr(unsafe.Pointer(pathBytes)), uintptr(unsafe.Pointer(&stat)), 0) + runtime.KeepAlive(pathBytes) + runtime.KeepAlive(&stat) + if errno != 0 { + return fmt.Errorf("stat: %w", errno) + } + return nil +} + +// statFstat creates a file and stats it via raw SYS_FSTAT (newfstat). +// This is an fd_event, so ior resolves the path via its fd lookup table. +func statFstat() error { + dir, cleanup, err := makeTempDir("stat-fstat") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "fstatfile.txt") + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } defer syscall.Close(fd) var stat syscall.Stat_t - if err := syscall.Fstat(fd, &stat); err != nil { - return fmt.Errorf("fstat: %w", err) + _, _, errno := syscall.Syscall(syscall.SYS_FSTAT, uintptr(fd), uintptr(unsafe.Pointer(&stat)), 0) + runtime.KeepAlive(&stat) + if errno != 0 { + return fmt.Errorf("fstat: %w", errno) + } + return nil +} + +// statLstat creates a file and stats it via raw SYS_LSTAT (newlstat). +// We use the raw syscall because Go's syscall.Lstat wraps newfstatat on amd64. +func statLstat() error { + dir, cleanup, err := makeTempDir("stat-lstat") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "lstatfile.txt") + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + syscall.Close(fd) + + var stat syscall.Stat_t + pathBytes, err := syscall.BytePtrFromString(path) + if err != nil { + return fmt.Errorf("path bytes: %w", err) + } + _, _, errno := syscall.Syscall(syscall.SYS_LSTAT, uintptr(unsafe.Pointer(pathBytes)), uintptr(unsafe.Pointer(&stat)), 0) + runtime.KeepAlive(pathBytes) + runtime.KeepAlive(&stat) + if errno != 0 { + return fmt.Errorf("lstat: %w", errno) } + return nil +} + +// statNewfstatat creates a file and stats it via Go's syscall.Stat, which +// wraps SYS_NEWFSTATAT (fstatat with AT_FDCWD) on amd64. +func statNewfstatat() error { + dir, cleanup, err := makeTempDir("stat-newfstatat") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "fstatatfile.txt") + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + syscall.Close(fd) + + var stat syscall.Stat_t if err := syscall.Stat(path, &stat); err != nil { - return fmt.Errorf("stat: %w", err) + return fmt.Errorf("newfstatat: %w", err) + } + return nil +} + +const ( + sysStatx = 332 + rOK = 0x4 // R_OK + statxBasicMask = 0x07ff // STATX_BASIC_STATS +) + +const atFDCwd = -100 // AT_FDCWD + +// statStatx creates a file and stats it via raw statx(2) syscall. +func statStatx() error { + dir, cleanup, err := makeTempDir("stat-statx") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "statxfile.txt") + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + syscall.Close(fd) + + pathBytes, err := syscall.BytePtrFromString(path) + if err != nil { + return fmt.Errorf("path bytes: %w", err) + } + var buf [256]byte // statx struct is ~256 bytes + _, _, errno := syscall.Syscall6( + sysStatx, + ^uintptr(99), // AT_FDCWD (-100) + uintptr(unsafe.Pointer(pathBytes)), + 0, + statxBasicMask, + uintptr(unsafe.Pointer(&buf[0])), + 0, + ) + runtime.KeepAlive(pathBytes) + runtime.KeepAlive(buf) + if errno != 0 { + return fmt.Errorf("statx: %w", errno) + } + return nil +} + +// statAccess creates a file and checks access via raw SYS_ACCESS. +// We use the raw syscall because Go's syscall.Access wraps faccessat on amd64. +func statAccess() error { + dir, cleanup, err := makeTempDir("stat-access") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "accessfile.txt") + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + syscall.Close(fd) + + pathBytes, err := syscall.BytePtrFromString(path) + if err != nil { + return fmt.Errorf("path bytes: %w", err) + } + _, _, errno := syscall.Syscall(syscall.SYS_ACCESS, uintptr(unsafe.Pointer(pathBytes)), rOK, 0) + runtime.KeepAlive(pathBytes) + if errno != 0 { + return fmt.Errorf("access: %w", errno) + } + return nil +} + +// statFaccessat creates a file and checks access via faccessat(2). +// Go's syscall.Faccessat wraps SYS_FACCESSAT. +func statFaccessat() error { + dir, cleanup, err := makeTempDir("stat-faccessat") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "faccessatfile.txt") + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + syscall.Close(fd) + + if err := syscall.Faccessat(atFDCwd, path, uint32(rOK), 0); err != nil { + return fmt.Errorf("faccessat: %w", err) } return nil } @@ -334,3 +1305,106 @@ func truncateBasic() error { } return syscall.Ftruncate(fd, 5) } + +// openByHandleAt creates a file, resolves its handle via name_to_handle_at, +// then opens it via open_by_handle_at. Requires root (CAP_DAC_READ_SEARCH). +// LockOSThread prevents goroutine migration between the two syscalls so that +// ior sees the same TID for both and can correlate the path. +func openByHandleAt() error { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + dir, cleanup, err := makeTempDir("open-by-handle-at") + if err != nil { + return err + } + defer cleanup() + + path := filepath.Join(dir, "handlefile.txt") + fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CREAT, 0o644) + if err != nil { + return fmt.Errorf("open: %w", err) + } + if err := syscall.Close(fd); err != nil { + return fmt.Errorf("close: %w", err) + } + + handle, mountFD, err := nameToHandleAt(dir, "handlefile.txt") + if err != nil { + return fmt.Errorf("name_to_handle_at: %w", err) + } + defer syscall.Close(mountFD) + + fd2, err := openByHandleAtSyscall(mountFD, handle, syscall.O_RDONLY) + if err != nil { + return fmt.Errorf("open_by_handle_at: %w", err) + } + return syscall.Close(fd2) +} + +// fileHandle matches the kernel's struct file_handle layout. +type fileHandle struct { + Size uint32 + Type int32 + // Handle bytes follow immediately after. +} + +const ( + sysNameToHandleAt = 303 + sysOpenByHandleAt = 304 +) + +// nameToHandleAt calls name_to_handle_at(2) and returns the file handle +// and the directory fd. The caller can pass this dirFD as the mount_fd +// argument to open_by_handle_at since any fd on the same filesystem works. +func nameToHandleAt(dirPath, name string) ([]byte, int, error) { + dirFD, err := syscall.Open(dirPath, syscall.O_RDONLY|syscall.O_DIRECTORY, 0) + if err != nil { + return nil, 0, fmt.Errorf("open dir: %w", err) + } + + nameBytes, err := syscall.BytePtrFromString(name) + if err != nil { + syscall.Close(dirFD) + return nil, 0, fmt.Errorf("name bytes: %w", err) + } + + // Start with a buffer large enough for most handles. + buf := make([]byte, unsafe.Sizeof(fileHandle{})+128) + fh := (*fileHandle)(unsafe.Pointer(&buf[0])) + fh.Size = uint32(len(buf) - int(unsafe.Sizeof(fileHandle{}))) + + var mountID int32 + _, _, errno := syscall.Syscall6( + sysNameToHandleAt, + uintptr(dirFD), + uintptr(unsafe.Pointer(nameBytes)), + uintptr(unsafe.Pointer(fh)), + uintptr(unsafe.Pointer(&mountID)), + 0, 0, + ) + if errno != 0 { + syscall.Close(dirFD) + return nil, 0, fmt.Errorf("syscall: %w", errno) + } + + handleLen := int(unsafe.Sizeof(fileHandle{})) + int(fh.Size) + handle := make([]byte, handleLen) + copy(handle, buf[:handleLen]) + + return handle, dirFD, nil +} + +// openByHandleAtSyscall calls open_by_handle_at(2). +func openByHandleAtSyscall(mountFD int, handle []byte, flags int) (int, error) { + fd, _, errno := syscall.Syscall( + sysOpenByHandleAt, + uintptr(mountFD), + uintptr(unsafe.Pointer(&handle[0])), + uintptr(flags), + ) + if errno != 0 { + return 0, fmt.Errorf("syscall: %w", errno) + } + return int(fd), nil +} diff --git a/integrationtests/dir_test.go b/integrationtests/dir_test.go new file mode 100644 index 0000000..67bbe93 --- /dev/null +++ b/integrationtests/dir_test.go @@ -0,0 +1,47 @@ +package integrationtests + +import "testing" + +func TestDirBasic(t *testing.T) { + runScenario(t, "dir-basic", []ExpectedEvent{ + { + PathContains: "subdir", + Tracepoint: "enter_mkdir", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} + +func TestDirMkdirat(t *testing.T) { + runScenario(t, "dir-mkdirat", []ExpectedEvent{ + { + PathContains: "mkdirat-subdir", + Tracepoint: "enter_mkdirat", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} + +func TestDirChdir(t *testing.T) { + runScenario(t, "dir-chdir", []ExpectedEvent{ + { + PathContains: "dir-chdir", + Tracepoint: "enter_chdir", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} + +func TestDirGetdents(t *testing.T) { + runScenario(t, "dir-getdents", []ExpectedEvent{ + { + PathContains: "dir-getdents", + Tracepoint: "enter_getdents64", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} diff --git a/integrationtests/dup_test.go b/integrationtests/dup_test.go new file mode 100644 index 0000000..6289465 --- /dev/null +++ b/integrationtests/dup_test.go @@ -0,0 +1,36 @@ +package integrationtests + +import "testing" + +func TestDupBasic(t *testing.T) { + runScenario(t, "dup-basic", []ExpectedEvent{ + { + PathContains: "dupfile.txt", + Tracepoint: "enter_dup", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} + +func TestDupDup2(t *testing.T) { + runScenario(t, "dup-dup2", []ExpectedEvent{ + { + PathContains: "dup2file.txt", + Tracepoint: "enter_dup2", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} + +func TestDupDup3(t *testing.T) { + runScenario(t, "dup-dup3", []ExpectedEvent{ + { + PathContains: "dup3file.txt", + Tracepoint: "enter_dup3", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} diff --git a/integrationtests/fcntl_test.go b/integrationtests/fcntl_test.go new file mode 100644 index 0000000..f9c95bd --- /dev/null +++ b/integrationtests/fcntl_test.go @@ -0,0 +1,36 @@ +package integrationtests + +import "testing" + +func TestFcntlDupfd(t *testing.T) { + runScenario(t, "fcntl-dupfd", []ExpectedEvent{ + { + PathContains: "fcntlfile.txt", + Tracepoint: "enter_fcntl", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} + +func TestFcntlSetfl(t *testing.T) { + runScenario(t, "fcntl-setfl", []ExpectedEvent{ + { + PathContains: "fcntlsetflfile.txt", + Tracepoint: "enter_fcntl", + Comm: "ioworkload", + MinCount: 2, + }, + }) +} + +func TestFcntlDupfdCloexec(t *testing.T) { + runScenario(t, "fcntl-dupfd-cloexec", []ExpectedEvent{ + { + PathContains: "fcntlcloexecfile.txt", + Tracepoint: "enter_fcntl", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} diff --git a/integrationtests/harness.go b/integrationtests/harness.go new file mode 100644 index 0000000..315fec4 --- /dev/null +++ b/integrationtests/harness.go @@ -0,0 +1,179 @@ +package integrationtests + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +const ( + workloadStartupTimeout = 5 * time.Second + iorShutdownGrace = 3 * time.Second +) + +// TestHarness orchestrates integration tests by starting an ior trace +// against a known ioworkload process and collecting the .ior.zst output. +type TestHarness struct { + IorBinary string // path to built ior binary + WorkloadBinary string // path to built ioworkload binary + BpfObject string // path to ior.bpf.o + OutputDir string // temp dir for .ior.zst output +} + +// Run executes a single integration test scenario. It starts the ioworkload +// binary, reads its PID from stdout, launches ior with a PID filter, waits +// for both to finish, and parses the resulting .ior.zst file. +func (h *TestHarness) Run(scenario string, duration int) (TestResult, int, error) { + workloadCmd, workloadPID, err := h.startWorkload(scenario) + if err != nil { + return TestResult{}, 0, err + } + + iorCmd, err := h.startIor(workloadPID, scenario, duration) + if err != nil { + workloadCmd.Process.Kill() + workloadCmd.Wait() + return TestResult{}, workloadPID, err + } + + workloadErr, iorErr := waitBoth(workloadCmd, iorCmd, duration) + + if iorErr != nil { + return TestResult{}, workloadPID, fmt.Errorf("ior: %w", iorErr) + } + if workloadErr != nil { + return TestResult{}, workloadPID, fmt.Errorf("workload: %w", workloadErr) + } + + iorFile, err := findIorZstFile(h.OutputDir, scenario) + if err != nil { + return TestResult{}, workloadPID, fmt.Errorf("find .ior.zst: %w", err) + } + + result, err := LoadTestResult(iorFile) + if err != nil { + return TestResult{}, workloadPID, fmt.Errorf("parse result: %w", err) + } + + return result, workloadPID, nil +} + +func (h *TestHarness) startWorkload(scenario string) (*exec.Cmd, int, error) { + cmd := exec.Command(h.WorkloadBinary, "--scenario="+scenario) + cmd.Stderr = os.Stderr + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, 0, fmt.Errorf("workload stdout pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return nil, 0, fmt.Errorf("start workload: %w", err) + } + + pidCh := make(chan int, 1) + errCh := make(chan error, 1) + go func() { + scanner := bufio.NewScanner(stdout) + if scanner.Scan() { + pid, err := strconv.Atoi(strings.TrimSpace(scanner.Text())) + if err != nil { + errCh <- fmt.Errorf("parse workload PID: %w", err) + return + } + pidCh <- pid + } else if err := scanner.Err(); err != nil { + errCh <- fmt.Errorf("reading workload stdout: %w", err) + } else { + errCh <- fmt.Errorf("workload produced no output") + } + }() + + select { + case pid := <-pidCh: + return cmd, pid, nil + case err := <-errCh: + cmd.Process.Kill() + cmd.Wait() + return nil, 0, err + case <-time.After(workloadStartupTimeout): + cmd.Process.Kill() + cmd.Wait() + return nil, 0, fmt.Errorf("timeout waiting for workload PID") + } +} + +func (h *TestHarness) startIor(pid int, scenario string, duration int) (*exec.Cmd, error) { + cmd := exec.Command(h.IorBinary, + "-pid", strconv.Itoa(pid), + "-flamegraph", + "-name", scenario, + "-duration", strconv.Itoa(duration), + ) + cmd.Dir = h.OutputDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("start ior: %w", err) + } + return cmd, nil +} + +// waitBoth waits for both the workload and ior commands concurrently. +// If ior does not finish within duration + grace period, it is killed. +func waitBoth(workloadCmd, iorCmd *exec.Cmd, duration int) (workloadErr, iorErr error) { + workloadDone := make(chan error, 1) + iorDone := make(chan error, 1) + + go func() { workloadDone <- workloadCmd.Wait() }() + go func() { iorDone <- iorCmd.Wait() }() + + timeout := time.After(time.Duration(duration)*time.Second + iorShutdownGrace) + + for workloadDone != nil || iorDone != nil { + select { + case err := <-workloadDone: + workloadErr = err + workloadDone = nil + case err := <-iorDone: + iorErr = err + iorDone = nil + case <-timeout: + if iorDone != nil { + iorCmd.Process.Kill() + iorErr = fmt.Errorf("ior timed out") + iorDone = nil + } + if workloadDone != nil { + workloadCmd.Process.Kill() + workloadErr = fmt.Errorf("workload timed out") + workloadDone = nil + } + return + } + } + return +} + +// findIorZstFile locates the .ior.zst file matching the scenario name in the output directory. +func findIorZstFile(dir, scenario string) (string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return "", fmt.Errorf("read output dir: %w", err) + } + + for _, e := range entries { + name := e.Name() + if strings.Contains(name, scenario) && strings.HasSuffix(name, ".ior.zst") { + return filepath.Join(dir, name), nil + } + } + + return "", fmt.Errorf("no .ior.zst file found for scenario %q in %s", scenario, dir) +} diff --git a/integrationtests/helpers_test.go b/integrationtests/helpers_test.go new file mode 100644 index 0000000..edf57b9 --- /dev/null +++ b/integrationtests/helpers_test.go @@ -0,0 +1,50 @@ +package integrationtests + +import ( + "os" + "path/filepath" + "testing" +) + +const ( + iorBinaryDefault = "../ior" + workloadBinaryDefault = "../ioworkload" + bpfObjectDefault = "../ior.bpf.o" + defaultDuration = 10 +) + +func newTestHarness(t *testing.T) TestHarness { + t.Helper() + if os.Geteuid() != 0 { + t.Skip("requires root for BPF") + } + + return TestHarness{ + IorBinary: absPath(t, iorBinaryDefault), + WorkloadBinary: absPath(t, workloadBinaryDefault), + BpfObject: absPath(t, bpfObjectDefault), + OutputDir: t.TempDir(), + } +} + +func absPath(t *testing.T, rel string) string { + t.Helper() + p, err := filepath.Abs(rel) + if err != nil { + t.Fatalf("resolve path %s: %v", rel, err) + } + return p +} + +func runScenario(t *testing.T, scenario string, expected []ExpectedEvent) { + t.Helper() + h := newTestHarness(t) + result, pid, err := h.Run(scenario, defaultDuration) + if err != nil { + t.Fatalf("run scenario %s: %v", scenario, err) + } + + AssertNoUnexpectedPID(t, result, pid) + AssertNoUnexpectedComm(t, result, "ioworkload") + AssertEventsPresent(t, result, expected) +} diff --git a/integrationtests/link_test.go b/integrationtests/link_test.go new file mode 100644 index 0000000..fcebffb --- /dev/null +++ b/integrationtests/link_test.go @@ -0,0 +1,59 @@ +package integrationtests + +import "testing" + +func TestLinkBasic(t *testing.T) { + runScenario(t, "link-basic", []ExpectedEvent{ + { + PathContains: "hardlink.txt", + Tracepoint: "enter_link", + Comm: "ioworkload", + MinCount: 1, + }, + { + PathContains: "symlink.txt", + Tracepoint: "enter_symlink", + Comm: "ioworkload", + MinCount: 1, + }, + { + PathContains: "symlink.txt", + Tracepoint: "enter_readlink", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} + +func TestLinkLinkat(t *testing.T) { + runScenario(t, "link-linkat", []ExpectedEvent{ + { + PathContains: "linkat-hard.txt", + Tracepoint: "enter_linkat", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} + +func TestLinkSymlinkat(t *testing.T) { + runScenario(t, "link-symlinkat", []ExpectedEvent{ + { + PathContains: "symlinkat-link.txt", + Tracepoint: "enter_symlinkat", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} + +func TestLinkReadlinkat(t *testing.T) { + runScenario(t, "link-readlinkat", []ExpectedEvent{ + { + PathContains: "readlinkat-link.txt", + Tracepoint: "enter_readlinkat", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} diff --git a/integrationtests/open_test.go b/integrationtests/open_test.go new file mode 100644 index 0000000..3f07ae3 --- /dev/null +++ b/integrationtests/open_test.go @@ -0,0 +1,36 @@ +package integrationtests + +import "testing" + +func TestOpenBasic(t *testing.T) { + runScenario(t, "open-basic", []ExpectedEvent{ + { + PathContains: "testfile.txt", + Tracepoint: "enter_openat", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} + +func TestOpenCreat(t *testing.T) { + runScenario(t, "open-creat", []ExpectedEvent{ + { + PathContains: "creatfile.txt", + Tracepoint: "enter_openat", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} + +func TestOpenByHandleAt(t *testing.T) { + runScenario(t, "open-by-handle-at", []ExpectedEvent{ + { + PathContains: "handlefile.txt", + Tracepoint: "enter_open_by_handle_at", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} diff --git a/integrationtests/readwrite_test.go b/integrationtests/readwrite_test.go new file mode 100644 index 0000000..3770ae1 --- /dev/null +++ b/integrationtests/readwrite_test.go @@ -0,0 +1,64 @@ +package integrationtests + +import "testing" + +func TestReadwriteBasic(t *testing.T) { + runScenario(t, "readwrite-basic", []ExpectedEvent{ + { + PathContains: "rwfile.txt", + Tracepoint: "enter_write", + Comm: "ioworkload", + MinCount: 1, + }, + { + PathContains: "rwfile.txt", + Tracepoint: "enter_read", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} + +func TestReadwritePread(t *testing.T) { + runScenario(t, "readwrite-pread", []ExpectedEvent{ + { + PathContains: "preadfile.txt", + Tracepoint: "enter_pread64", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} + +func TestReadwritePwrite(t *testing.T) { + runScenario(t, "readwrite-pwrite", []ExpectedEvent{ + { + PathContains: "pwritefile.txt", + Tracepoint: "enter_pwrite64", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} + +func TestReadwriteReadv(t *testing.T) { + runScenario(t, "readwrite-readv", []ExpectedEvent{ + { + PathContains: "readvfile.txt", + Tracepoint: "enter_readv", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} + +func TestReadwriteWritev(t *testing.T) { + runScenario(t, "readwrite-writev", []ExpectedEvent{ + { + PathContains: "writevfile.txt", + Tracepoint: "enter_writev", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} diff --git a/integrationtests/rename_test.go b/integrationtests/rename_test.go new file mode 100644 index 0000000..8e3238d --- /dev/null +++ b/integrationtests/rename_test.go @@ -0,0 +1,36 @@ +package integrationtests + +import "testing" + +func TestRenameBasic(t *testing.T) { + runScenario(t, "rename-basic", []ExpectedEvent{ + { + PathContains: "oldname.txt", + Tracepoint: "enter_rename", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} + +func TestRenameRenameat(t *testing.T) { + runScenario(t, "rename-renameat", []ExpectedEvent{ + { + PathContains: "renameat-old.txt", + Tracepoint: "enter_renameat", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} + +func TestRenameRenameat2(t *testing.T) { + runScenario(t, "rename-renameat2", []ExpectedEvent{ + { + PathContains: "renameat2-old.txt", + Tracepoint: "enter_renameat2", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} diff --git a/integrationtests/stat_test.go b/integrationtests/stat_test.go new file mode 100644 index 0000000..e38171a --- /dev/null +++ b/integrationtests/stat_test.go @@ -0,0 +1,80 @@ +package integrationtests + +import "testing" + +func TestStatBasic(t *testing.T) { + runScenario(t, "stat-basic", []ExpectedEvent{ + { + PathContains: "statfile.txt", + Tracepoint: "enter_newstat", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} + +func TestStatFstat(t *testing.T) { + runScenario(t, "stat-fstat", []ExpectedEvent{ + { + PathContains: "fstatfile.txt", + Tracepoint: "enter_newfstat", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} + +func TestStatLstat(t *testing.T) { + runScenario(t, "stat-lstat", []ExpectedEvent{ + { + PathContains: "lstatfile.txt", + Tracepoint: "enter_newlstat", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} + +func TestStatNewfstatat(t *testing.T) { + runScenario(t, "stat-newfstatat", []ExpectedEvent{ + { + PathContains: "fstatatfile.txt", + Tracepoint: "enter_newfstatat", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} + +func TestStatStatx(t *testing.T) { + runScenario(t, "stat-statx", []ExpectedEvent{ + { + PathContains: "statxfile.txt", + Tracepoint: "enter_statx", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} + +func TestStatAccess(t *testing.T) { + runScenario(t, "stat-access", []ExpectedEvent{ + { + PathContains: "accessfile.txt", + Tracepoint: "enter_access", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} + +func TestStatFaccessat(t *testing.T) { + runScenario(t, "stat-faccessat", []ExpectedEvent{ + { + PathContains: "faccessatfile.txt", + Tracepoint: "enter_faccessat", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} diff --git a/integrationtests/unlink_test.go b/integrationtests/unlink_test.go new file mode 100644 index 0000000..942f43d --- /dev/null +++ b/integrationtests/unlink_test.go @@ -0,0 +1,36 @@ +package integrationtests + +import "testing" + +func TestUnlinkBasic(t *testing.T) { + runScenario(t, "unlink-basic", []ExpectedEvent{ + { + PathContains: "unlinkme.txt", + Tracepoint: "enter_unlink", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} + +func TestUnlinkUnlinkat(t *testing.T) { + runScenario(t, "unlink-unlinkat", []ExpectedEvent{ + { + PathContains: "unlinkat-file.txt", + Tracepoint: "enter_unlinkat", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} + +func TestUnlinkRmdir(t *testing.T) { + runScenario(t, "unlink-rmdir", []ExpectedEvent{ + { + PathContains: "rmdir-me", + Tracepoint: "enter_rmdir", + Comm: "ioworkload", + MinCount: 1, + }, + }) +} |
