diff --git a/Makefile b/Makefile index 1152b66..24842e5 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: generate build test vet cover release docs docs-clean clean format lint +.PHONY: generate build test vet cover release docs docs-clean clean format lint platformtest .DEFAULT_GOAL := build ARTIFACTDIR := artifacts @@ -54,6 +54,13 @@ vet: GOOS=linux GOARCH=arm64 $(GO) vet $(GO_BUILDFLAGS) ./... GOOS=darwin GOARCH=amd64 $(GO) vet $(GO_BUILDFLAGS) ./... +ZREPL_PLATFORMTEST_POOLNAME := zreplplatformtest +ZREPL_PLATFORMTEST_IMAGEPATH := /tmp/zreplplatformtest.pool.img +$(ARTIFACTDIR)/zrepl_platformtest: + $(GO_BUILD) -o "$(ARTIFACTDIR)/zrepl_platformtest" ./platformtest/harness +platformtest: $(ARTIFACTDIR)/zrepl_platformtest + "$(ARTIFACTDIR)/zrepl_platformtest" -poolname "$(ZREPL_PLATFORMTEST_POOLNAME)" -imagepath "$(ZREPL_PLATFORMTEST_IMAGEPATH)" + $(ARTIFACTDIR): mkdir -p "$@" diff --git a/README.md b/README.md index c0c1022..aff0783 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Check out the *Coding Workflow* section below for details. * Ship other material provided in `./dist`, e.g. in `/usr/share/zrepl/`. * Use `make release ZREPL_VERSION='mydistro-1.2.3_1'` * Your distro's name and any versioning supplemental to zrepl's (e.g. package revision) should be in this string +* Use `make platformtest` **on a test system** to validate that zrepl's abstractions on top of ZFS work with the system ZFS. * Make sure you are informed about new zrepl versions, e.g. by subscribing to GitHub's release RSS feed. ## Developer Documentation diff --git a/docs/changelog.rst b/docs/changelog.rst index 30b077f..2da6c0c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -45,6 +45,10 @@ We use the following annotations for classifying changes: * |bugfix| rpc goroutine leak in ``push`` mode if zfs recv fails on the ``sink`` side * [MAINTAINER NOTICE] Go modules for dependency management both inside and outside of GOPATH (``lazy.sh`` and ``Makefile`` force ``GO111MODULE=on``) +* [MAINTAINER NOTICE] ``make platformtest`` target to check zrepl's ZFS abstractions (screen scraping, etc.). + These tests only work on a system with ZFS installed, and must be run as root because they create a file-backed pool for each test case. + The pool name ``zreplplatformtest`` is reserved for this use case. + Only run ``make platformtest`` on test systems, e.g. a FreeBSD VM image. 0.1.1 ----- diff --git a/platformtest/harness/harness.go b/platformtest/harness/harness.go index 48c5ea4..3a87359 100644 --- a/platformtest/harness/harness.go +++ b/platformtest/harness/harness.go @@ -4,9 +4,11 @@ import ( "context" "flag" "fmt" + "path/filepath" "time" "github.com/fatih/color" + "github.com/pkg/errors" "github.com/zrepl/zrepl/config" "github.com/zrepl/zrepl/daemon/logging" "github.com/zrepl/zrepl/logger" @@ -16,11 +18,14 @@ import ( func main() { - root := flag.String("root", "", "empty root filesystem under which we conduct the platform test") - flag.Parse() - if *root == "" { - panic(*root) + var args struct { + createArgs platformtest.ZpoolCreateArgs } + flag.StringVar(&args.createArgs.PoolName, "poolname", "", "") + flag.StringVar(&args.createArgs.ImagePath, "imagepath", "", "") + flag.Int64Var(&args.createArgs.ImageSize, "imagesize", 100*(1<<20), "") + flag.StringVar(&args.createArgs.Mountpoint, "mountpoint", "none", "") + flag.Parse() outlets := logger.NewOutlets() outlet, level, err := logging.ParseOutlet(config.LoggingOutletEnum{Ret: &config.StdoutLoggingOutlet{ @@ -35,17 +40,40 @@ func main() { outlets.Add(outlet, level) logger := logger.NewLogger(outlets, 1*time.Second) - ctx := &platformtest.Context{ - Context: platformtest.WithLogger(context.Background(), logger), - RootDataset: *root, + if err := args.createArgs.Validate(); err != nil { + logger.Error(err.Error()) + panic(err) } + ctx := platformtest.WithLogger(context.Background(), logger) + ex := platformtest.NewEx(logger) bold := color.New(color.Bold) for _, c := range tests.Cases { - bold.Printf("BEGIN TEST CASE %s\n", c) - c(ctx) - bold.Printf("DONE TEST CASE %s\n", c) - fmt.Println() + // ATTENTION future parallelism must pass c by value into closure! + err := func() error { + bold.Printf("BEGIN TEST CASE %s\n", c) + pool, err := platformtest.CreateOrReplaceZpool(ctx, ex, args.createArgs) + if err != nil { + return errors.Wrap(err, "create test pool") + } + defer func() { + if err := pool.Destroy(ctx, ex); err != nil { + fmt.Printf("error destroying test pool: %s", err) + } + }() + ctx := &platformtest.Context{ + Context: ctx, + RootDataset: filepath.Join(pool.Name(), "rootds"), + } + c(ctx) + bold.Printf("DONE TEST CASE %s\n", c) + fmt.Println() + return nil + }() + if err != nil { + bold.Printf("TEST CASE FAILED WITH ERROR:\n") + fmt.Println(err) + } } } diff --git a/platformtest/harness/harness_test.go b/platformtest/harness/harness_test.go index ae529c1..08f002b 100644 --- a/platformtest/harness/harness_test.go +++ b/platformtest/harness/harness_test.go @@ -9,14 +9,14 @@ import ( // Idea taken from // https://github.com/openSUSE/umoci/blob/v0.2.1/cmd/umoci/main_test.go // -// How to generate coverage: -// 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 \ -// -test.v __DEVEL--i-heard-you-like-tests \ -// -root rpool/zreplplayground -// go tool cover -html=/tmp/harness.out -o /tmp/harness.html -// +/* How to generate coverage: + 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 \ + -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 +*/ // Merge with existing coverage reports using gocovmerge: // https://github.com/wadey/gocovmerge diff --git a/platformtest/platformtest_exec.go b/platformtest/platformtest_exec.go index d137a15..70b9aea 100644 --- a/platformtest/platformtest_exec.go +++ b/platformtest/platformtest_exec.go @@ -4,13 +4,15 @@ import ( "context" "fmt" "os/exec" + + "github.com/zrepl/zrepl/util/circlog" ) type ex struct { log Logger } -func newEx(log Logger) *ex { +func NewEx(log Logger) Execer { return &ex{log} } @@ -27,14 +29,15 @@ func (e *ex) runNoOutput(expectSuccess bool, ctx context.Context, cmd string, ar log.Debug("begin executing") defer log.Debug("done executing") ecmd := exec.CommandContext(ctx, cmd, args...) - // use circlog and capture stdout + buf, _ := circlog.NewCircularLog(32 << 10) + ecmd.Stdout, ecmd.Stderr = buf, buf err := ecmd.Run() - ee, ok := err.(*exec.ExitError) - if err != nil && !ok { + log.Printf("command output: %s", buf.String()) + if _, ok := err.(*exec.ExitError); err != nil && !ok { panic(err) } if expectSuccess && err != nil { - return fmt.Errorf("expecting no error, got error: %s\n%s", err, ee.Stderr) + return fmt.Errorf("expecting no error, got error: %s\n%s", err, buf.String()) } else if !expectSuccess && err == nil { return fmt.Errorf("expecting error, got no error") } diff --git a/platformtest/platformtest_ops.go b/platformtest/platformtest_ops.go index 617180e..0c2c6c0 100644 --- a/platformtest/platformtest_ops.go +++ b/platformtest/platformtest_ops.go @@ -37,26 +37,11 @@ type DestroyRootOp struct { } func (o *DestroyRootOp) Run(ctx context.Context, e Execer) error { - const magicName = "zreplplayground" - // sanity check: root must contain - if !strings.Contains(o.Path, magicName) { - panic("sanity check failed") - } // early-exit if it doesn't exist if err := e.RunExpectSuccessNoOutput(ctx, "zfs", "get", "-H", "name", o.Path); err != nil { getLog(ctx).WithField("root_ds", o.Path).Info("assume root ds doesn't exist") return nil } - op, err := exec.CommandContext(ctx, "zfs", "destroy", "-nvrp", o.Path).CombinedOutput() - if err != nil { - return fmt.Errorf("cannot clean root dataset %q: %s\n%s", o.Path, err, op) - } - sc := bufio.NewScanner(bytes.NewReader(op)) - for sc.Scan() { - if !strings.Contains(sc.Text(), magicName) { - panic("sanity check failed") - } - } return e.RunExpectSuccessNoOutput(ctx, "zfs", "destroy", "-r", o.Path) } @@ -141,7 +126,7 @@ func Run(ctx context.Context, rk RunKind, rootds string, stmtsStr string) { if err != nil { panic(err) } - execer := newEx(getLog(ctx)) + execer := NewEx(getLog(ctx)) for _, s := range stmt { err := s.Run(ctx, execer) if err == nil { diff --git a/platformtest/platformtest_zpool.go b/platformtest/platformtest_zpool.go new file mode 100644 index 0000000..5801226 --- /dev/null +++ b/platformtest/platformtest_zpool.go @@ -0,0 +1,92 @@ +package platformtest + +import ( + "context" + "os" + "path/filepath" + + "github.com/pkg/errors" + "github.com/zrepl/zrepl/zfs" +) + +type Zpool struct { + 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 != "none" { + return errors.Errorf("Mountpoint must be \"none\"") + } + if a.PoolName == "" { + return errors.Errorf("PoolName must not be emtpy") + } + 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) + if _, err := zfs.ZFSGetRawAnySource(args.PoolName, []string{"name"}); err != nil { + if _, ok := err.(*zfs.DatasetDoesNotExist); ok { + // we'll create it shortly + } else { + return nil, errors.Wrapf(err, "cannot determine whether test pool %q exists", args.PoolName) + } + } else { + // exists, export it, OpenFile will destroy it + if err := e.RunExpectSuccessNoOutput(ctx, "zpool", "export", args.PoolName); err != nil { + return nil, errors.Wrapf(err, "cannot destroy test pool %q", args.PoolName) + } + } + + // idempotently (re)create the pool image + image, err := os.OpenFile(args.ImagePath, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return nil, errors.Wrap(err, "create image file") + } + defer image.Close() + if err := image.Truncate(args.ImageSize); err != nil { + return nil, errors.Wrap(err, "create image: truncate") + } + image.Close() + + // create the pool + err = e.RunExpectSuccessNoOutput(ctx, "zpool", "create", "-O", "mountpoint=none", args.PoolName, args.ImagePath) + if err != nil { + return nil, errors.Wrap(err, "zpool create") + } + + return &Zpool{args}, nil +} + +func (p *Zpool) Name() string { return p.args.PoolName } + +func (p *Zpool) Destroy(ctx context.Context, e Execer) error { + + if err := e.RunExpectSuccessNoOutput(ctx, "zpool", "export", p.args.PoolName); err != nil { + return errors.Wrapf(err, "export pool %q", p.args.PoolName) + } + + if err := os.Remove(p.args.ImagePath); err != nil { + return errors.Wrapf(err, "remove pool image") + } + + return nil +}