Compare commits

...

3 Commits

Author SHA1 Message Date
Christian Schwarz 83c4bceeb5 WIP 2026-02-13 21:55:25 +00:00
Christian Schwarz 74e23fea43 platformtest: add package doc comment to interposer.go
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:02:58 +00:00
Christian Schwarz b3f24861c4 platformtest: replace logmockzfs with multi-personality safety interposer
Replace the shell-script-based logmockzfs wrapper with a Go-native
multi-personality binary (busybox-style). The platformtest binary now
acts as a safety interposer for zfs/zpool when invoked via symlinks,
validating that all commands reference the hardcoded test pool before
delegating through sudo.

Key changes:
- New interposer.go: SetupInterposerPath creates a tmpdir with symlinks
  and replaces PATH; RunInterposer validates args and delegates via sudo
- Pool name, image path, and mountpoint are now hardcoded constants
- Remove ZpoolCreateArgs, Zpool struct, and CLI flags for pool config
- Remove logmockzfs/ shell scripts (logzfsenv, zfs wrapper)
- Simplify Makefile: no more logmockzfs invocation or root check
- Add -no-interposer flag for direct execution as root
- Safety: block -a (all) flags without pool reference, validate
  mountpoint paths against traversal, command-aware flag parsing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:56:38 +00:00
8 changed files with 398 additions and 152 deletions
+2 -15
View File
@@ -273,7 +273,7 @@ cover-platform-bin:
$(GO_ENV_VARS) $(GO) test $(GO_BUILDFLAGS) \ $(GO_ENV_VARS) $(GO) test $(GO_BUILDFLAGS) \
-c -o "$(COVER_PLATFORM_BIN_PATH)" \ -c -o "$(COVER_PLATFORM_BIN_PATH)" \
-covermode=atomic -cover -coverpkg github.com/zrepl/zrepl/... \ -covermode=atomic -cover -coverpkg github.com/zrepl/zrepl/... \
./platformtest/harness ./internal/platformtest/harness
cover-platform: cover-platform:
# do not track dependency on cover-platform-bin to allow build of binary outside of test VM # do not track dependency on cover-platform-bin to allow build of binary outside of test VM
export _TEST_PLATFORM_CMD="$(COVER_PLATFORM_BIN_PATH) \ export _TEST_PLATFORM_CMD="$(COVER_PLATFORM_BIN_PATH) \
@@ -289,25 +289,13 @@ test-platform:
export _TEST_PLATFORM_CMD="\"$(TEST_PLATFORM_BIN_PATH)\""; \ export _TEST_PLATFORM_CMD="\"$(TEST_PLATFORM_BIN_PATH)\""; \
$(MAKE) _test-or-cover-platform-impl $(MAKE) _test-or-cover-platform-impl
ZREPL_PLATFORMTEST_POOLNAME := zreplplatformtest
ZREPL_PLATFORMTEST_IMAGEPATH := /tmp/zreplplatformtest.pool.img
ZREPL_PLATFORMTEST_MOUNTPOINT := /tmp/zreplplatformtest.pool
ZREPL_PLATFORMTEST_ZFS_LOG := /tmp/zreplplatformtest.zfs.log
# ZREPL_PLATFORMTEST_STOP_AND_KEEP := -failure.stop-and-keep-pool
ZREPL_PLATFORMTEST_ARGS := ZREPL_PLATFORMTEST_ARGS :=
_test-or-cover-platform-impl: $(ARTIFACTDIR) _test-or-cover-platform-impl: $(ARTIFACTDIR)
ifndef _TEST_PLATFORM_CMD ifndef _TEST_PLATFORM_CMD
$(error _TEST_PLATFORM_CMD is undefined, caller 'cover-platform' or 'test-platform' should have defined it) $(error _TEST_PLATFORM_CMD is undefined, caller 'cover-platform' or 'test-platform' should have defined it)
endif endif
rm -f "$(ZREPL_PLATFORMTEST_ZFS_LOG)"
rm -f "$(ARTIFACTDIR)/platformtest.cover" rm -f "$(ARTIFACTDIR)/platformtest.cover"
internal/platformtest/logmockzfs/logzfsenv "$(ZREPL_PLATFORMTEST_ZFS_LOG)" `which zfs` \ $(_TEST_PLATFORM_CMD) $(ZREPL_PLATFORMTEST_ARGS)
$(_TEST_PLATFORM_CMD) \
-poolname "$(ZREPL_PLATFORMTEST_POOLNAME)" \
-imagepath "$(ZREPL_PLATFORMTEST_IMAGEPATH)" \
-mountpoint "$(ZREPL_PLATFORMTEST_MOUNTPOINT)" \
$(ZREPL_PLATFORMTEST_STOP_AND_KEEP) \
$(ZREPL_PLATFORMTEST_ARGS)
cover-merge: $(ARTIFACTDIR) cover-merge: $(ARTIFACTDIR)
$(GOCOVMERGE) $(ARTIFACTDIR)/platformtest.cover $(ARTIFACTDIR)/gotest.cover > $(ARTIFACTDIR)/merged.cover $(GOCOVMERGE) $(ARTIFACTDIR)/platformtest.cover $(ARTIFACTDIR)/gotest.cover > $(ARTIFACTDIR)/merged.cover
@@ -315,7 +303,6 @@ cover-html: cover-merge
$(GO) tool cover -html "$(ARTIFACTDIR)/merged.cover" -o "$(ARTIFACTDIR)/merged.cover.html" $(GO) tool cover -html "$(ARTIFACTDIR)/merged.cover" -o "$(ARTIFACTDIR)/merged.cover.html"
cover-full: cover-full:
test "$$(id -u)" = "0" || echo "MUST RUN AS ROOT" 1>&2
$(MAKE) test-go COVER=1 $(MAKE) test-go COVER=1
$(MAKE) cover-platform-bin $(MAKE) cover-platform-bin
$(MAKE) cover-platform $(MAKE) cover-platform
+9 -13
View File
@@ -101,24 +101,20 @@ Along with the main ``zrepl`` binary, we release the ``platformtest`` binaries.
The zrepl platform tests are an integration test suite that is complementary to the pure Go unit tests. The zrepl platform tests are an integration test suite that is complementary to the pure Go unit tests.
Any test that needs to interact with ZFS is a platform test. Any test that needs to interact with ZFS is a platform test.
The platform need to run as root. The platform tests require ``sudo`` ``NOPASSWD``-access to ``zfs`` and ``zpool``.
For each test, we create a fresh dummy zpool backed by a file-based vdev. For each test, we create a fresh dummy zpool backed by a file-based vdev.
The file path, and a root mountpoint for the dummy zpool, must be specified on the command line: The pool name, image path, and mountpoint are hardcoded in the binary.
::
mkdir -p /tmp/zreplplatformtest
./platformtest \
-poolname 'zreplplatformtest' \ # <- name must contain zreplplatformtest
-imagepath /tmp/zreplplatformtest.img \ # <- zrepl will create the file
-mountpoint /tmp/zreplplatformtest # <- must exist
The ``platformtest`` binary is a multi-personality binary (similar to busybox).
It acts as a safety interposer for ``zfs`` and ``zpool`` commands, ensuring all operations
reference the hard-coded test pool name ``zreplplatformtest``. It creates symlinks to itself in a temporary
directory and replaces ``PATH`` so that all ``zfs``/``zpool`` invocations go through the interposer.
.. WARNING:: .. WARNING::
``platformtest`` will unconditionally overwrite the file at `imagepath` ``platformtest`` will unconditionally ``zpool destroy`` the test zpool
and unconditionally ``zpool destroy $poolname``. and unconditionally delete the image file that backs it.
So, don't use a production poolname, and consider running the test in a VM. Consider running the test in a VM.
It'll be a lot faster as well because the underlying operations, ``zfs list`` in particular, will be faster. It'll be a lot faster as well because the underlying operations, ``zfs list`` in particular, will be faster.
+25 -18
View File
@@ -26,18 +26,23 @@ var bold = color.New(color.Bold)
var boldRed = color.New(color.Bold, color.FgHiRed) var boldRed = color.New(color.Bold, color.FgHiRed)
var boldGreen = color.New(color.Bold, color.FgHiGreen) var boldGreen = color.New(color.Bold, color.FgHiGreen)
const DefaultPoolImageSize = 200 * (1 << 20)
func main() { func main() {
// Multi-personality binary: check argv[0] to determine mode.
// When invoked via a symlink named "zfs" or "zpool", act as
// a safety interposer that validates commands and delegates
// to the real binary via sudo.
switch filepath.Base(os.Args[0]) {
case "zfs":
os.Exit(platformtest.RunInterposer("zfs"))
case "zpool":
os.Exit(platformtest.RunInterposer("zpool"))
}
// Test harness mode
var args HarnessArgs var args HarnessArgs
flag.BoolVar(&args.NoInterposer, "no-interposer", false, "skip safety interposer; symlink zfs/zpool directly to real binaries (requires running as root)")
flag.StringVar(&args.CreateArgs.PoolName, "poolname", "", "")
flag.StringVar(&args.CreateArgs.ImagePath, "imagepath", "", "")
flag.Int64Var(&args.CreateArgs.ImageSize, "imagesize", DefaultPoolImageSize, "")
flag.StringVar(&args.CreateArgs.Mountpoint, "mountpoint", "", "")
flag.BoolVar(&args.StopAndKeepPoolOnFail, "failure.stop-and-keep-pool", false, "if a test case fails, stop test execution and keep pool as it was when the test failed") flag.BoolVar(&args.StopAndKeepPoolOnFail, "failure.stop-and-keep-pool", false, "if a test case fails, stop test execution and keep pool as it was when the test failed")
flag.StringVar(&args.Run, "run", "", "") flag.StringVar(&args.Run, "run", "", "regex to filter test cases")
flag.DurationVar(&platformtest.ZpoolExportTimeout, "zfs.zpool-export-timeout", platformtest.ZpoolExportTimeout, "") flag.DurationVar(&platformtest.ZpoolExportTimeout, "zfs.zpool-export-timeout", platformtest.ZpoolExportTimeout, "")
flag.Parse() flag.Parse()
@@ -49,7 +54,7 @@ func main() {
var exitWithErr = fmt.Errorf("exit with error") var exitWithErr = fmt.Errorf("exit with error")
type HarnessArgs struct { type HarnessArgs struct {
CreateArgs platformtest.ZpoolCreateArgs NoInterposer bool
StopAndKeepPoolOnFail bool StopAndKeepPoolOnFail bool
Run string Run string
} }
@@ -90,6 +95,14 @@ func (i *invocation) RegisterChild(c *invocation) error {
func HarnessRun(args HarnessArgs) error { func HarnessRun(args HarnessArgs) error {
// Set up interposer: create symlinks and replace PATH.
// In direct mode, zfs/zpool symlink to real binaries (no sudo, no validation).
_, cleanup, err := platformtest.SetupInterposerPath("", args.NoInterposer)
if err != nil {
return errors.Wrap(err, "set up interposer PATH")
}
defer cleanup()
runRE := regexp.MustCompile(args.Run) runRE := regexp.MustCompile(args.Run)
outlets := logger.NewOutlets() outlets := logger.NewOutlets()
@@ -104,11 +117,6 @@ func HarnessRun(args HarnessArgs) error {
} }
outlets.Add(outlet, level) outlets.Add(outlet, level)
logger := logger.NewLogger(outlets, 1*time.Second) logger := logger.NewLogger(outlets, 1*time.Second)
if err := args.CreateArgs.Validate(); err != nil {
logger.Error(err.Error())
panic(err)
}
ctx := context.Background() ctx := context.Background()
defer trace.WithTaskFromStackUpdateCtx(&ctx)() defer trace.WithTaskFromStackUpdateCtx(&ctx)()
ctx = logging.WithLoggers(ctx, logging.SubsystemLoggersWithUniversalLogger(logger)) ctx = logging.WithLoggers(ctx, logging.SubsystemLoggersWithUniversalLogger(logger))
@@ -128,14 +136,13 @@ func HarnessRun(args HarnessArgs) error {
bold.Printf("BEGIN TEST CASE %s\n", inv) bold.Printf("BEGIN TEST CASE %s\n", inv)
pool, err := platformtest.CreateOrReplaceZpool(ctx, ex, args.CreateArgs) if err := platformtest.CreateOrReplaceZpool(ctx, ex); err != nil {
if err != nil {
panic(errors.Wrap(err, "create test pool")) panic(errors.Wrap(err, "create test pool"))
} }
ctx := &platformtest.Context{ ctx := &platformtest.Context{
Context: ctx, Context: ctx,
RootDataset: filepath.Join(pool.Name(), "rootds"), RootDataset: filepath.Join(platformtest.PlatformTestPoolName, "rootds"),
QueueSubtest: func(id string, stf func(*platformtest.Context)) { QueueSubtest: func(id string, stf func(*platformtest.Context)) {
stinv := newInvocation(stf, id) stinv := newInvocation(stf, id)
err := inv.RegisterChild(stinv) err := inv.RegisterChild(stinv)
@@ -158,7 +165,7 @@ func HarnessRun(args HarnessArgs) error {
return exitWithErr return exitWithErr
} }
if err := pool.Destroy(ctx, ex); err != nil { if err := platformtest.DestroyZpool(ctx, ex); err != nil {
panic(fmt.Sprintf("error destroying test pool: %s", err)) panic(fmt.Sprintf("error destroying test pool: %s", err))
} }
+12 -3
View File
@@ -3,8 +3,11 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/zrepl/zrepl/internal/platformtest"
) )
// Idea taken from // Idea taken from
@@ -12,16 +15,22 @@ import (
// //
/* How to generate coverage: /* How to generate coverage:
go test -c -covermode=atomic -cover -coverpkg github.com/zrepl/zrepl/... go test -c -covermode=atomic -cover -coverpkg github.com/zrepl/zrepl/...
sudo ../logmockzfs/logzfsenv /tmp/zrepl_platform_test.log /usr/bin/zfs \
./harness.test -test.coverprofile=/tmp/harness.out \ ./harness.test -test.coverprofile=/tmp/harness.out \
-test.v __DEVEL--i-heard-you-like-tests \ -test.v __DEVEL--i-heard-you-like-tests
-imagepath /tmp/testpool.img -poolname zreplplatformtest
go tool cover -html=/tmp/harness.out -o /tmp/harness.html go tool cover -html=/tmp/harness.out -o /tmp/harness.html
*/ */
// Merge with existing coverage reports using gocovmerge: // Merge with existing coverage reports using gocovmerge:
// https://github.com/wadey/gocovmerge // https://github.com/wadey/gocovmerge
func TestMain(t *testing.T) { func TestMain(t *testing.T) {
// Multi-personality: when invoked as zfs/zpool symlink, run interposer
switch filepath.Base(os.Args[0]) {
case "zfs":
os.Exit(platformtest.RunInterposer("zfs"))
case "zpool":
os.Exit(platformtest.RunInterposer("zpool"))
}
fmt.Println("incoming args: ", os.Args) fmt.Println("incoming args: ", os.Args)
var ( var (
+322
View File
@@ -0,0 +1,322 @@
// # Safety Interposer
//
// The platformtest binary is a multi-personality binary (busybox-style).
// When invoked as "zfs" or "zpool" (via argv[0]), it acts as a safety
// interposer that validates commands and delegates to the real binary
// via sudo.
//
// SetupInterposerPath creates a temp directory with symlinks and replaces PATH:
// - zfs/zpool → the platformtest binary itself (interposer mode)
// - zfs.real/zpool.real → actual binaries (for delegation via sudo)
// - sudo/bash → real binaries (so they remain accessible)
//
// When invoked as zfs or zpool, the binary validates that all commands
// reference the test pool "zreplplatformtest" before delegating through
// sudo. The validation rule is simple: the pool name must appear in the
// command args. Special cases:
// - Resume tokens ("zfs send -t <token>"): decoded via the real binary
// to extract and verify the toname field
// - The -a (all) flag is blocked without an explicit pool reference
// - Bare subcommands (feature detection) are allowed
// - Mountpoint paths are validated against traversal on post-create chmod
//
// With -no-interposer, zfs/zpool symlink directly to the real binaries
// (no sudo, no validation). Use this when running as root.
package platformtest
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// SetupInterposerPath creates a temp directory with zfs/zpool symlinks
// and replaces PATH so that all zfs/zpool invocations go through it.
//
// If `explicitDir` is non-empty, the PATH is constructed in that directory
// instead of a temp directory, and the caller is responsible for cleanup.
//
// `direct` controls whether to set up the interposer (false) or to symlink
// zfs/zpool directly to the real binaries (true, no interposer, no sudo).
//
// Returns the directory path and a cleanup function.
func SetupInterposerPath(explicitDir string, direct bool) (string, func(), error) {
// Resolve real binary paths while original PATH is still intact.
lookupNames := []string{"zfs", "zpool", "bash"}
if !direct {
lookupNames = append(lookupNames, "sudo")
}
realBinaries := make(map[string]string)
for _, name := range lookupNames {
p, err := exec.LookPath(name)
if err != nil {
return "", nil, fmt.Errorf("find real %s binary: %w", name, err)
}
realBinaries[name] = p
}
var dir string
var cleanup func()
if explicitDir != "" {
dir = explicitDir
cleanup = func() {} // caller manages lifecycle
} else {
var err error
dir, err = os.MkdirTemp("", "zrepl-platformtest-*")
if err != nil {
return "", nil, fmt.Errorf("create interposer dir: %w", err)
}
cleanup = func() { os.RemoveAll(dir) }
}
if direct {
// Direct mode: symlink zfs/zpool to real binaries (no interposer).
for _, name := range []string{"zfs", "zpool", "bash"} {
link := filepath.Join(dir, name)
os.Remove(link)
if err := os.Symlink(realBinaries[name], link); err != nil {
cleanup()
return "", nil, fmt.Errorf("create symlink %s: %w", name, err)
}
}
} else {
// Interposer mode: symlink zfs/zpool to self, .real to actual binaries.
self, err := os.Executable()
if err != nil {
cleanup()
return "", nil, fmt.Errorf("get executable path: %w", err)
}
for _, name := range []string{"zfs", "zpool"} {
link := filepath.Join(dir, name)
os.Remove(link)
if err := os.Symlink(self, link); err != nil {
cleanup()
return "", nil, fmt.Errorf("create symlink %s: %w", name, err)
}
realLink := filepath.Join(dir, name+".real")
os.Remove(realLink)
if err := os.Symlink(realBinaries[name], realLink); err != nil {
cleanup()
return "", nil, fmt.Errorf("create symlink %s.real: %w", name, err)
}
}
// Symlink other required binaries so they remain accessible
// after PATH replacement.
for _, name := range []string{"sudo", "bash"} {
link := filepath.Join(dir, name)
os.Remove(link)
if err := os.Symlink(realBinaries[name], link); err != nil {
cleanup()
return "", nil, fmt.Errorf("create symlink %s: %w", name, err)
}
}
}
// Replace PATH entirely so ALL zfs/zpool calls go through this directory.
os.Setenv("PATH", dir)
return dir, cleanup, nil
}
// RunInterposer handles the zfs/zpool interposer mode.
// It validates that the command references the test pool,
// then executes the real binary via sudo using the .real
// symlink placed by SetupInterposerPath.
func RunInterposer(command string) int {
args := os.Args[1:]
if err := validatePoolName(command, args, PlatformTestPoolName); err != nil {
fmt.Fprintf(os.Stderr, "SAFETY: %v\n", err)
return 1
}
// The .real symlink in PATH points to the actual binary.
// sudo follows the symlink, so no explicit resolution needed.
realBinary := filepath.Join(os.Getenv("PATH"), command+".real")
sudoArgs := append([]string{realBinary}, args...)
cmd := exec.Command("sudo", sudoArgs...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
// After a successful create, chmod the mountpoint so the
// unprivileged test user can operate on it.
if err == nil && isCreateOperation(args) {
if mountpoint := extractCreateMountpoint(command, args); mountpoint != "" {
chmodMountpoint(mountpoint)
}
}
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return exitErr.ExitCode()
}
fmt.Fprintf(os.Stderr, "interposer: exec error: %v\n", err)
return 1
}
return 0
}
func validatePoolName(command string, args []string, poolName string) error {
if len(args) == 0 {
return nil
}
// Check if any non-flag argument references the pool name.
for _, arg := range args {
if strings.HasPrefix(arg, "-") {
continue
}
if arg == poolName || strings.HasPrefix(arg, poolName+"/") || strings.HasPrefix(arg, poolName+"@") || strings.HasPrefix(arg, poolName+"#") {
return nil
}
}
// Special case: "zfs send -t <token>" (or -nvt, etc.) — the pool
// name is encoded in the resume token, not visible in the args.
// Decode the token via the real binary and verify toname.
if command == "zfs" {
if token := extractResumeToken(args); token != "" {
return validateResumeTokenPool(token, poolName)
}
}
// Block -a (all) flag without pool reference. Commands like
// "zfs unmount -a" or "zpool export -a" affect all pools.
for _, arg := range args {
if !strings.HasPrefix(arg, "-") {
continue
}
if arg == "-a" || (len(arg) > 2 && arg[0] == '-' && arg[1] != '-' &&
strings.ContainsRune(arg[1:], 'a')) {
return fmt.Errorf("%s command rejected: -a flag not allowed without explicit pool reference", command)
}
}
// If only one non-flag arg is present (the subcommand itself),
// this is feature detection (e.g., bare "zfs send", "zfs receive").
nonFlagArgs := 0
for _, arg := range args {
if !strings.HasPrefix(arg, "-") {
nonFlagArgs++
}
}
if nonFlagArgs <= 1 {
return nil
}
return fmt.Errorf("%s command rejected: args %v do not reference pool %q", command, args, poolName)
}
// extractResumeToken returns the resume token from "zfs send -t <token>"
// or combined forms like "zfs send -nvt <token>". Returns "" if not found.
func extractResumeToken(args []string) string {
if len(args) == 0 || args[0] != "send" {
return ""
}
for i, arg := range args {
if !strings.HasPrefix(arg, "-") {
continue
}
// -t standalone, or combined flag containing t (e.g., -nvt)
if arg == "-t" || (len(arg) > 2 && arg[0] == '-' && arg[1] != '-' &&
strings.ContainsRune(arg[1:], 't')) {
if i+1 < len(args) {
return args[i+1]
}
}
}
return ""
}
// validateResumeTokenPool decodes a resume token by calling the real zfs
// binary directly (bypassing the interposer) and checks that the toname
// field references the expected pool.
func validateResumeTokenPool(token, poolName string) error {
realBinary := filepath.Join(os.Getenv("PATH"), "zfs.real")
cmd := exec.Command("sudo", realBinary, "send", "-nvt", token)
output, err := cmd.CombinedOutput()
if err != nil {
// zfs send -nvt may exit non-zero if the dataset no longer exists,
// but it still prints the token contents. Only fail on exec errors.
if _, ok := err.(*exec.ExitError); !ok {
return fmt.Errorf("decode resume token: %w", err)
}
}
for _, line := range strings.Split(string(output), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "toname = ") {
toname := strings.TrimPrefix(line, "toname = ")
if toname == poolName || strings.HasPrefix(toname, poolName+"/") || strings.HasPrefix(toname, poolName+"@") {
return nil
}
return fmt.Errorf("resume token rejected: toname %q does not reference pool %q", toname, poolName)
}
}
return fmt.Errorf("resume token rejected: could not extract toname from token")
}
func isCreateOperation(args []string) bool {
return len(args) > 0 && args[0] == "create"
}
func extractCreateMountpoint(command string, args []string) string {
switch command {
case "zpool":
// zpool create ... -O mountpoint=<path> ...
for i, arg := range args {
if arg == "-O" && i+1 < len(args) {
if strings.HasPrefix(args[i+1], "mountpoint=") {
return strings.TrimPrefix(args[i+1], "mountpoint=")
}
}
}
case "zfs":
// zfs create [-p] [-o ...] <dataset>
// Dataset is the last non-flag argument. Its mountpoint is
// derived from the pool mountpoint + dataset path suffix.
dataset := ""
skipNext := false
for _, arg := range args[1:] { // skip "create"
if skipNext {
skipNext = false
continue
}
if arg == "-o" {
skipNext = true
continue
}
if strings.HasPrefix(arg, "-") {
continue
}
dataset = arg
}
if dataset != "" && strings.HasPrefix(dataset, PlatformTestPoolName+"/") {
suffix := strings.TrimPrefix(dataset, PlatformTestPoolName)
return PlatformTestMountpoint + suffix
}
}
return ""
}
func chmodMountpoint(mountpoint string) {
// Validate: only chmod paths under the test mountpoint to prevent
// path traversal (e.g., dataset "pool/../../etc" -> mountpoint "/etc").
cleaned := filepath.Clean(mountpoint)
if cleaned != PlatformTestMountpoint && !strings.HasPrefix(cleaned, PlatformTestMountpoint+"/") {
fmt.Fprintf(os.Stderr, "interposer: refusing to chmod %q (not under %s)\n",
mountpoint, PlatformTestMountpoint)
return
}
// Best-effort: make mountpoint accessible to unprivileged user.
exec.Command("sudo", "chmod", "0777", cleaned).Run() //nolint:errcheck
}
@@ -1,24 +0,0 @@
#!/usr/bin/env bash
set -ue
export ZREPL_MOCK_ZFS_COMMAND_LOG="$1"
shift
export ZREPL_MOCK_ZFS_PATH="$1"
shift
dirname="$(dirname "${BASH_SOURCE[0]}")"
# If we invoke this script from the top zrepl source tree, like so:
# ./platformtest/logmockzfs/logzfsenv ...
# then dirname is relative, i.e., ./platformtest/logmockzfs.
# If we put that relative dir in PATH, then Go >= 1.19 will refuse to
# exec the `./platformtest/logmockzfs/zfs` wrapper script if it finds
# it via PATH lookup. For Example:
# cmd := exec.Command("zfs")
# err := cmd.Run()
# will fail with an error that errors.Is(err, exec.ErrDot), message:
# cannot run executable found relative to current directory
# The solution is to use an abspath.
# Learn more at https://pkg.go.dev/os/exec#hdr-Executables_in_the_current_directory
# and https://go-review.googlesource.com/c/go/+/381374
absdirname="$(readlink -e "$dirname")"
export PATH="$absdirname":"$PATH"
args=("$@")
exec "${args[@]}"
-12
View File
@@ -1,12 +0,0 @@
#!/usr/bin/env bash
set -eu
args=("$@")
if [ -n "$ZREPL_MOCK_ZFS_COMMAND_LOG" ]; then
(
flock -x 200
jq --compact-output -n --argjson args "$(printf '%s\0' "${args[@]}" | jq -Rsc 'split("\u0000")')" '{date: now|strflocaltime("%Y-%m-%dT%H:%M:%S"), command: $args}' >> "$ZREPL_MOCK_ZFS_COMMAND_LOG"
) 200>"$ZREPL_MOCK_ZFS_COMMAND_LOG".lock
fi
exec "$ZREPL_MOCK_ZFS_PATH" "${args[@]}"
+27 -66
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"path/filepath"
"runtime" "runtime"
"strings" "strings"
"time" "time"
@@ -14,97 +13,63 @@ import (
"github.com/zrepl/zrepl/internal/zfs" "github.com/zrepl/zrepl/internal/zfs"
) )
const (
PlatformTestPoolName = "zreplplatformtest"
PlatformTestImagePath = "/tmp/zreplplatformtest.pool.img"
PlatformTestMountpoint = "/tmp/zreplplatformtest.pool"
PlatformTestImageSize = 200 * (1 << 20) // 200 MiB
)
var ZpoolExportTimeout time.Duration = 500 * time.Millisecond var ZpoolExportTimeout time.Duration = 500 * time.Millisecond
type Zpool struct { func CreateOrReplaceZpool(ctx context.Context, e Execer) error {
args ZpoolCreateArgs
}
type ZpoolCreateArgs struct {
PoolName string
ImagePath string
ImageSize int64
Mountpoint string
}
func (a ZpoolCreateArgs) Validate() error {
if !filepath.IsAbs(a.ImagePath) {
return errors.Errorf("ImagePath must be absolute, got %q", a.ImagePath)
}
const minImageSize = 1024
if a.ImageSize < minImageSize {
return errors.Errorf("ImageSize must be > %v, got %v", minImageSize, a.ImageSize)
}
if a.Mountpoint == "" || a.Mountpoint[0] != '/' {
return errors.Errorf("Mountpoint must be an absolute path to a directory")
}
if a.PoolName == "" {
return errors.Errorf("PoolName must not be empty")
}
return nil
}
func CreateOrReplaceZpool(ctx context.Context, e Execer, args ZpoolCreateArgs) (*Zpool, error) {
if err := args.Validate(); err != nil {
return nil, errors.Wrap(err, "zpool create args validation error")
}
// export pool if it already exists (idempotence) // export pool if it already exists (idempotence)
if _, err := zfs.ZFSGetRawAnySource(ctx, args.PoolName, []string{"name"}); err != nil { if _, err := zfs.ZFSGetRawAnySource(ctx, PlatformTestPoolName, []string{"name"}); err != nil {
if _, ok := err.(*zfs.DatasetDoesNotExist); ok { if _, ok := err.(*zfs.DatasetDoesNotExist); ok {
// we'll create it shortly // we'll create it shortly
} else { } else {
return nil, errors.Wrapf(err, "cannot determine whether test pool %q exists", args.PoolName) return errors.Wrapf(err, "cannot determine whether test pool %q exists", PlatformTestPoolName)
} }
} else { } else {
// exists, export it, OpenFile will destroy it // exists, export it, OpenFile will destroy it
if err := e.RunExpectSuccessNoOutput(ctx, "zpool", "export", args.PoolName); err != nil { if err := e.RunExpectSuccessNoOutput(ctx, "zpool", "export", PlatformTestPoolName); err != nil {
return nil, errors.Wrapf(err, "cannot destroy test pool %q", args.PoolName) return errors.Wrapf(err, "cannot destroy test pool %q", PlatformTestPoolName)
} }
} }
// clear the mountpoint dir
if err := os.RemoveAll(args.Mountpoint); err != nil {
return nil, errors.Wrapf(err, "remove mountpoint dir %q", args.Mountpoint)
}
if err := os.Mkdir(args.Mountpoint, 0700); err != nil {
return nil, errors.Wrapf(err, "create mountpoint dir %q", args.Mountpoint)
}
// idempotently (re)create the pool image // idempotently (re)create the pool image
image, err := os.OpenFile(args.ImagePath, os.O_CREATE|os.O_RDWR, 0600) image, err := os.OpenFile(PlatformTestImagePath, os.O_CREATE|os.O_RDWR, 0600)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "create image file") return errors.Wrap(err, "create image file")
} }
defer image.Close() defer image.Close()
if err := image.Truncate(args.ImageSize); err != nil { if err := image.Truncate(PlatformTestImageSize); err != nil {
return nil, errors.Wrap(err, "create image: truncate") return errors.Wrap(err, "create image: truncate")
} }
image.Close() image.Close()
// create the pool // create the pool (zpool runs via sudo through the interposer,
// which creates the mountpoint and chmods it to 0777)
err = e.RunExpectSuccessNoOutput(ctx, "zpool", "create", "-f", err = e.RunExpectSuccessNoOutput(ctx, "zpool", "create", "-f",
"-O", fmt.Sprintf("mountpoint=%s", args.Mountpoint), "-O", fmt.Sprintf("mountpoint=%s", PlatformTestMountpoint),
args.PoolName, args.ImagePath, PlatformTestPoolName, PlatformTestImagePath,
) )
if err != nil { if err != nil {
return nil, errors.Wrap(err, "zpool create") return errors.Wrap(err, "zpool create")
} }
return &Zpool{args}, nil return nil
} }
func (p *Zpool) Name() string { return p.args.PoolName } func DestroyZpool(ctx context.Context, e Execer) error {
func (p *Zpool) Destroy(ctx context.Context, e Execer) error {
exportDeadline := time.Now().Add(ZpoolExportTimeout) exportDeadline := time.Now().Add(ZpoolExportTimeout)
for { for {
if time.Now().After(exportDeadline) { if time.Now().After(exportDeadline) {
return errors.Errorf("could not zpool export (got 'pool is busy'): %s", p.args.PoolName) return errors.Errorf("could not zpool export (got 'pool is busy'): %s", PlatformTestPoolName)
} }
err := e.RunExpectSuccessNoOutput(ctx, "zpool", "export", p.args.PoolName) err := e.RunExpectSuccessNoOutput(ctx, "zpool", "export", PlatformTestPoolName)
if err == nil { if err == nil {
break break
} }
@@ -113,17 +78,13 @@ func (p *Zpool) Destroy(ctx context.Context, e Execer) error {
continue continue
} }
if err != nil { if err != nil {
return errors.Wrapf(err, "export pool %q", p.args.PoolName) return errors.Wrapf(err, "export pool %q", PlatformTestPoolName)
} }
} }
if err := os.Remove(p.args.ImagePath); err != nil { if err := os.Remove(PlatformTestImagePath); err != nil {
return errors.Wrapf(err, "remove pool image") return errors.Wrapf(err, "remove pool image")
} }
if err := os.RemoveAll(p.args.Mountpoint); err != nil {
return errors.Wrapf(err, "remove mountpoint dir %q", p.args.Mountpoint)
}
return nil return nil
} }