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 }