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:
@@ -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
@@ -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.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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[@]}"
|
|
||||||
@@ -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[@]}"
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user