zrepl/internal/platformtest/platformtest_ops.go
2024-10-18 19:21:17 +02:00

326 lines
7.6 KiB
Go

package platformtest
import (
"bufio"
"bytes"
"context"
"fmt"
"os"
"os/exec"
"strings"
"unicode"
)
type Execer interface {
RunExpectSuccessNoOutput(ctx context.Context, cmd string, args ...string) error
RunExpectFailureNoOutput(ctx context.Context, cmd string, args ...string) error
}
type Stmt interface {
Run(context context.Context, e Execer) error
}
type Op string
const (
Comment Op = "#"
AssertExists Op = "!E"
AssertNotExists Op = "!N"
Add Op = "+"
Del Op = "-"
RunCmd Op = "R"
DestroyRoot Op = "DESTROYROOT"
CreateRoot Op = "CREATEROOT"
)
type DestroyRootOp struct {
Path string
}
func (o *DestroyRootOp) Run(ctx context.Context, e Execer) error {
// 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
}
return e.RunExpectSuccessNoOutput(ctx, "zfs", "destroy", "-r", o.Path)
}
type FSOp struct {
Op Op
Path string
Encrypted bool // only for Op=Add
}
func (o *FSOp) Run(ctx context.Context, e Execer) error {
switch o.Op {
case AssertExists:
return e.RunExpectSuccessNoOutput(ctx, "zfs", "get", "-H", "name", o.Path)
case AssertNotExists:
return e.RunExpectFailureNoOutput(ctx, "zfs", "get", "-H", "name", o.Path)
case Add:
opts := []string{"create"}
if o.Encrypted {
const passphraseFilePath = "/tmp/zreplplatformtest.encryption.passphrase"
const passphrase = "foobar2342"
err := os.WriteFile(passphraseFilePath, []byte(passphrase), 0600)
if err != nil {
panic(err)
}
opts = append(opts,
"-o", "encryption=on",
"-o", "keylocation=file:///"+passphraseFilePath,
"-o", "keyformat=passphrase",
)
}
opts = append(opts, o.Path)
return e.RunExpectSuccessNoOutput(ctx, "zfs", opts...)
case Del:
return e.RunExpectSuccessNoOutput(ctx, "zfs", "destroy", o.Path)
default:
panic(o.Op)
}
}
type SnapOp struct {
Op Op
Path string
}
func (o *SnapOp) Run(ctx context.Context, e Execer) error {
switch o.Op {
case AssertExists:
return e.RunExpectSuccessNoOutput(ctx, "zfs", "get", "-H", "name", o.Path)
case AssertNotExists:
return e.RunExpectFailureNoOutput(ctx, "zfs", "get", "-H", "name", o.Path)
case Add:
return e.RunExpectSuccessNoOutput(ctx, "zfs", "snapshot", o.Path)
case Del:
return e.RunExpectSuccessNoOutput(ctx, "zfs", "destroy", o.Path)
default:
panic(o.Op)
}
}
type BookmarkOp struct {
Op Op
Existing string
Bookmark string
}
func (o *BookmarkOp) Run(ctx context.Context, e Execer) error {
switch o.Op {
case Add:
return e.RunExpectSuccessNoOutput(ctx, "zfs", "bookmark", o.Existing, o.Bookmark)
case Del:
if o.Existing != "" {
panic("existing must be empty for destroy, got " + o.Existing)
}
return e.RunExpectSuccessNoOutput(ctx, "zfs", "destroy", o.Bookmark)
default:
panic(o.Op)
}
}
type RunOp struct {
RootDS string
Script string
}
func (o *RunOp) Run(ctx context.Context, e Execer) error {
cmd := exec.CommandContext(ctx, "/usr/bin/env", "bash", "-c", o.Script)
cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, fmt.Sprintf("ROOTDS=%s", o.RootDS))
log := GetLog(ctx).WithField("script", o.Script)
log.Info("start script")
defer log.Info("script done")
output, err := cmd.CombinedOutput()
if _, ok := err.(*exec.ExitError); err != nil && !ok {
panic(err)
}
log.Printf("script output:\n%s", output)
return err
}
type LineError struct {
Line string
What string
}
func (e LineError) Error() string {
return fmt.Sprintf("%q: %s", e.Line, e.What)
}
type RunKind int
const (
PanicErr RunKind = 1 << iota
RunAll
)
func Run(ctx context.Context, rk RunKind, rootds string, stmtsStr string) {
stmt, err := parseSequence(rootds, stmtsStr)
if err != nil {
panic(err)
}
execer := NewEx(GetLog(ctx))
for _, s := range stmt {
err := s.Run(ctx, execer)
if err == nil {
continue
}
if rk == PanicErr {
panic(err)
} else if rk == RunAll {
continue
} else {
panic(rk)
}
}
}
func isNoSpace(r rune) bool {
return !unicode.IsSpace(r)
}
func splitQuotedWords(data []byte, atEOF bool) (advance int, token []byte, err error) {
begin := bytes.IndexFunc(data, isNoSpace)
if begin == -1 {
return len(data), nil, nil
}
if data[begin] == '"' {
end := begin + 1
for end < len(data) {
endCandidate := bytes.Index(data[end:], []byte(`"`))
if endCandidate == -1 {
return 0, nil, nil
}
end += endCandidate
if data[end-1] != '\\' {
// unescaped quote, end of this string
// remove backslash-escapes
withBackslash := data[begin+1 : end]
withoutBackslash := bytes.Replace(withBackslash, []byte("\\\""), []byte("\""), -1)
return end + 1, withoutBackslash, nil
} else {
// continue to next quote
end += 1
}
}
} else {
endOffset := bytes.IndexFunc(data[begin:], unicode.IsSpace)
var end int
if endOffset == -1 {
if !atEOF {
return 0, nil, nil
} else {
end = len(data)
}
} else {
end = begin + endOffset
}
return end, data[begin:end], nil
}
return 0, nil, fmt.Errorf("unexpected")
}
func parseSequence(rootds, stmtsStr string) (stmts []Stmt, err error) {
scan := bufio.NewScanner(strings.NewReader(stmtsStr))
nextLine:
for scan.Scan() {
if len(bytes.TrimSpace(scan.Bytes())) == 0 {
continue
}
comps := bufio.NewScanner(bytes.NewReader(scan.Bytes()))
comps.Split(splitQuotedWords)
expectMoreTokens := func() error {
if !comps.Scan() {
return &LineError{scan.Text(), "unexpected EOL"}
}
return nil
}
// Op
if err := expectMoreTokens(); err != nil {
return nil, err
}
var op Op
switch comps.Text() {
case string(RunCmd):
script := strings.TrimPrefix(strings.TrimSpace(scan.Text()), string(RunCmd))
stmts = append(stmts, &RunOp{RootDS: rootds, Script: script})
continue nextLine
case string(DestroyRoot):
if comps.Scan() {
return nil, &LineError{scan.Text(), "unexpected tokens at EOL"}
}
stmts = append(stmts, &DestroyRootOp{rootds})
continue nextLine
case string(CreateRoot):
if comps.Scan() {
return nil, &LineError{scan.Text(), "unexpected tokens at EOL"}
}
stmts = append(stmts, &FSOp{Op: Add, Path: rootds})
continue nextLine
case string(Add):
op = Add
case string(Del):
op = Del
case string(AssertExists):
op = AssertExists
case string(AssertNotExists):
op = AssertNotExists
case string(Comment):
op = Comment
continue
default:
return nil, &LineError{scan.Text(), fmt.Sprintf("invalid op %q", comps.Text())}
}
// FS / SNAP / BOOKMARK
if err := expectMoreTokens(); err != nil {
return nil, err
}
if strings.ContainsAny(comps.Text(), "@") {
stmts = append(stmts, &SnapOp{Op: op, Path: fmt.Sprintf("%s/%s", rootds, comps.Text())})
} else if strings.ContainsAny(comps.Text(), "#") {
bookmark := fmt.Sprintf("%s/%s", rootds, comps.Text())
if err := expectMoreTokens(); err != nil {
return nil, err
}
existing := fmt.Sprintf("%s/%s", rootds, comps.Text())
stmts = append(stmts, &BookmarkOp{Op: op, Existing: existing, Bookmark: bookmark})
} else {
// FS
fs := comps.Text()
var encrypted bool = false
if op == Add {
if comps.Scan() {
t := comps.Text()
switch t {
case "encrypted":
encrypted = true
default:
panic(fmt.Sprintf("unexpected token %q", t))
}
}
}
stmts = append(stmts, &FSOp{
Op: op,
Path: fmt.Sprintf("%s/%s", rootds, fs),
Encrypted: encrypted,
})
}
if comps.Scan() {
return nil, &LineError{scan.Text(), "unexpected tokens at EOL"}
}
}
return stmts, nil
}