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>
This commit is contained in:
Christian Schwarz
2026-02-10 19:56:38 +00:00
parent c393f35453
commit b3f24861c4
8 changed files with 357 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))
} }
+13 -4
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 (
+281
View File
@@ -0,0 +1,281 @@
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.
//
// When direct is false (normal mode), zfs/zpool symlink to the current
// executable (interposer mode: validates commands and delegates via sudo).
// Additional .real symlinks point to the actual binaries.
//
// When direct is true, zfs/zpool symlink directly to the real binaries
// (no interposer, no sudo). Use this when running as root.
//
// 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.
// In direct mode we only need zfs/zpool and bash; in interposer
// mode we also need sudo.
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 {
// Allow subcommands that don't reference a specific dataset,
// used for feature detection (e.g., "zfs send" with no dataset,
// "zfs receive" with no dataset, "zpool get").
if len(args) == 0 {
return nil
}
// Some subcommands are inherently safe / don't target a pool.
// Allow them unconditionally. The real safety net is that we
// only have sudo for zfs/zpool, not for anything destructive
// outside the test pool.
//
// 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
}
}
// Block commands with -a (all) flag when no pool name was found.
// These operate on ALL pools/filesystems (e.g., zfs unmount -a,
// zpool export -a) and must not be allowed without a pool reference.
for _, arg := range args {
if !strings.HasPrefix(arg, "-") {
continue
}
// -a standalone or in combined flags (e.g., -fa, -ra)
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)
}
}
// Special case: bare subcommand with only flags (feature detection).
// Count non-flag args. If there's only one (the subcommand itself),
// allow it.
//
// Note: -s is intentionally NOT in the skip list. It is boolean in
// some subcommands (e.g., zfs recv -s) and argument-taking in others
// (e.g., zfs list -s property). Not skipping its "value" is safe:
// commands with the pool name are already handled by the loop above,
// and for commands without a pool name, over-counting non-flag args
// causes a safe rejection rather than a dangerous acceptance.
nonFlagArgs := 0
skipNext := false
for _, arg := range args {
if skipNext {
skipNext = false
continue
}
// Flags that take a value argument: -o, -O always; -t for zfs only.
// -t is arg-taking in zfs (list -t type, send -t token) but
// boolean in zpool (import -t = temporary, events -t = timestamps).
argTakingChars := "oO"
if command == "zfs" {
argTakingChars = "oOt"
}
if arg == "-o" || arg == "-O" || (command == "zfs" && arg == "-t") ||
(len(arg) > 2 && arg[0] == '-' && arg[1] != '-' &&
strings.ContainsAny(arg, argTakingChars)) {
skipNext = true
continue
}
if strings.HasPrefix(arg, "-") {
continue
}
nonFlagArgs++
}
// If only the subcommand is present (e.g., "send", "receive", "get"),
// this is likely feature detection.
if nonFlagArgs <= 1 {
return nil
}
return fmt.Errorf("%s command rejected: args %v do not reference pool %q", command, args, poolName)
}
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
} }