refactor: decouple fingerprinting from executor (#1039)

This commit is contained in:
Pete Davison
2023-03-10 18:27:30 +00:00
committed by GitHub
parent c64f8818be
commit 0838d48ee3
24 changed files with 734 additions and 241 deletions

31
internal/env/env.go vendored Normal file
View File

@@ -0,0 +1,31 @@
package env
import (
"fmt"
"os"
"github.com/go-task/task/v3/taskfile"
)
func Get(t *taskfile.Task) []string {
if t.Env == nil {
return nil
}
environ := os.Environ()
for k, v := range t.Env.ToCacheMap() {
str, isString := v.(string)
if !isString {
continue
}
if _, alreadySet := os.LookupEnv(k); alreadySet {
continue
}
environ = append(environ, fmt.Sprintf("%s=%s", k, str))
}
return environ
}

View File

@@ -0,0 +1,20 @@
package fingerprint
import (
"context"
"github.com/go-task/task/v3/taskfile"
)
// StatusCheckable defines any type that can check if the status of a task is up-to-date.
type StatusCheckable interface {
IsUpToDate(ctx context.Context, t *taskfile.Task) (bool, error)
}
// SourcesCheckable defines any type that can check if the sources of a task are up-to-date.
type SourcesCheckable interface {
IsUpToDate(t *taskfile.Task) (bool, error)
Value(t *taskfile.Task) (interface{}, error)
OnError(t *taskfile.Task) error
Kind() string
}

View File

@@ -0,0 +1,132 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: checker.go
// Package fingerprint is a generated GoMock package.
package fingerprint
import (
context "context"
reflect "reflect"
taskfile "github.com/go-task/task/v3/taskfile"
gomock "github.com/golang/mock/gomock"
)
// MockStatusCheckable is a mock of StatusCheckable interface.
type MockStatusCheckable struct {
ctrl *gomock.Controller
recorder *MockStatusCheckableMockRecorder
}
// MockStatusCheckableMockRecorder is the mock recorder for MockStatusCheckable.
type MockStatusCheckableMockRecorder struct {
mock *MockStatusCheckable
}
// NewMockStatusCheckable creates a new mock instance.
func NewMockStatusCheckable(ctrl *gomock.Controller) *MockStatusCheckable {
mock := &MockStatusCheckable{ctrl: ctrl}
mock.recorder = &MockStatusCheckableMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockStatusCheckable) EXPECT() *MockStatusCheckableMockRecorder {
return m.recorder
}
// IsUpToDate mocks base method.
func (m *MockStatusCheckable) IsUpToDate(ctx context.Context, t *taskfile.Task) (bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "IsUpToDate", ctx, t)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// IsUpToDate indicates an expected call of IsUpToDate.
func (mr *MockStatusCheckableMockRecorder) IsUpToDate(ctx, t interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsUpToDate", reflect.TypeOf((*MockStatusCheckable)(nil).IsUpToDate), ctx, t)
}
// MockSourcesCheckable is a mock of SourcesCheckable interface.
type MockSourcesCheckable struct {
ctrl *gomock.Controller
recorder *MockSourcesCheckableMockRecorder
}
// MockSourcesCheckableMockRecorder is the mock recorder for MockSourcesCheckable.
type MockSourcesCheckableMockRecorder struct {
mock *MockSourcesCheckable
}
// NewMockSourcesCheckable creates a new mock instance.
func NewMockSourcesCheckable(ctrl *gomock.Controller) *MockSourcesCheckable {
mock := &MockSourcesCheckable{ctrl: ctrl}
mock.recorder = &MockSourcesCheckableMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockSourcesCheckable) EXPECT() *MockSourcesCheckableMockRecorder {
return m.recorder
}
// IsUpToDate mocks base method.
func (m *MockSourcesCheckable) IsUpToDate(t *taskfile.Task) (bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "IsUpToDate", t)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// IsUpToDate indicates an expected call of IsUpToDate.
func (mr *MockSourcesCheckableMockRecorder) IsUpToDate(t interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsUpToDate", reflect.TypeOf((*MockSourcesCheckable)(nil).IsUpToDate), t)
}
// Kind mocks base method.
func (m *MockSourcesCheckable) Kind() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Kind")
ret0, _ := ret[0].(string)
return ret0
}
// Kind indicates an expected call of Kind.
func (mr *MockSourcesCheckableMockRecorder) Kind() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Kind", reflect.TypeOf((*MockSourcesCheckable)(nil).Kind))
}
// OnError mocks base method.
func (m *MockSourcesCheckable) OnError(t *taskfile.Task) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "OnError", t)
ret0, _ := ret[0].(error)
return ret0
}
// OnError indicates an expected call of OnError.
func (mr *MockSourcesCheckableMockRecorder) OnError(t interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OnError", reflect.TypeOf((*MockSourcesCheckable)(nil).OnError), t)
}
// Value mocks base method.
func (m *MockSourcesCheckable) Value(t *taskfile.Task) (interface{}, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Value", t)
ret0, _ := ret[0].(interface{})
ret1, _ := ret[1].(error)
return ret0, ret1
}
// Value indicates an expected call of Value.
func (mr *MockSourcesCheckableMockRecorder) Value(t interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Value", reflect.TypeOf((*MockSourcesCheckable)(nil).Value), t)
}

View File

@@ -1,4 +1,4 @@
package status
package fingerprint
import (
"os"

View File

@@ -0,0 +1,16 @@
package fingerprint
import "fmt"
func NewSourcesChecker(method, tempDir string, dry bool) (SourcesCheckable, error) {
switch method {
case "timestamp":
return NewTimestampChecker(tempDir, dry), nil
case "checksum":
return NewChecksumChecker(tempDir, dry), nil
case "none":
return NoneChecker{}, nil
default:
return nil, fmt.Errorf(`task: invalid method "%s"`, method)
}
}

View File

@@ -1,4 +1,4 @@
package status
package fingerprint
import (
"crypto/md5"
@@ -10,51 +10,54 @@ import (
"strings"
"github.com/go-task/task/v3/internal/filepathext"
"github.com/go-task/task/v3/taskfile"
)
// Checksum validades if a task is up to date by calculating its source
// ChecksumChecker validates if a task is up to date by calculating its source
// files checksum
type Checksum struct {
TempDir string
TaskDir string
Task string
Sources []string
Generates []string
Dry bool
type ChecksumChecker struct {
tempDir string
dry bool
}
// IsUpToDate implements the Checker interface
func (c *Checksum) IsUpToDate() (bool, error) {
if len(c.Sources) == 0 {
func NewChecksumChecker(tempDir string, dry bool) *ChecksumChecker {
return &ChecksumChecker{
tempDir: tempDir,
dry: dry,
}
}
func (checker *ChecksumChecker) IsUpToDate(t *taskfile.Task) (bool, error) {
if len(t.Sources) == 0 {
return false, nil
}
checksumFile := c.checksumFilePath()
checksumFile := checker.checksumFilePath(t)
data, _ := os.ReadFile(checksumFile)
oldMd5 := strings.TrimSpace(string(data))
sources, err := globs(c.TaskDir, c.Sources)
sources, err := globs(t.Dir, t.Sources)
if err != nil {
return false, err
}
newMd5, err := c.checksum(sources...)
newMd5, err := checker.checksum(sources...)
if err != nil {
return false, nil
}
if !c.Dry {
_ = os.MkdirAll(filepathext.SmartJoin(c.TempDir, "checksum"), 0o755)
if !checker.dry {
_ = os.MkdirAll(filepathext.SmartJoin(checker.tempDir, "checksum"), 0o755)
if err = os.WriteFile(checksumFile, []byte(newMd5+"\n"), 0o644); err != nil {
return false, err
}
}
if len(c.Generates) > 0 {
if len(t.Generates) > 0 {
// For each specified 'generates' field, check whether the files actually exist
for _, g := range c.Generates {
generates, err := Glob(c.TaskDir, g)
for _, g := range t.Generates {
generates, err := Glob(t.Dir, g)
if os.IsNotExist(err) {
return false, nil
}
@@ -70,7 +73,22 @@ func (c *Checksum) IsUpToDate() (bool, error) {
return oldMd5 == newMd5, nil
}
func (c *Checksum) checksum(files ...string) (string, error) {
func (checker *ChecksumChecker) Value(t *taskfile.Task) (interface{}, error) {
return checker.checksum()
}
func (checker *ChecksumChecker) OnError(t *taskfile.Task) error {
if len(t.Sources) == 0 {
return nil
}
return os.Remove(checker.checksumFilePath(t))
}
func (*ChecksumChecker) Kind() string {
return "checksum"
}
func (c *ChecksumChecker) checksum(files ...string) (string, error) {
h := md5.New()
for _, f := range files {
@@ -91,31 +109,13 @@ func (c *Checksum) checksum(files ...string) (string, error) {
return fmt.Sprintf("%x", h.Sum(nil)), nil
}
// Value implements the Checker Interface
func (c *Checksum) Value() (interface{}, error) {
return c.checksum()
}
// OnError implements the Checker interface
func (c *Checksum) OnError() error {
if len(c.Sources) == 0 {
return nil
}
return os.Remove(c.checksumFilePath())
}
// Kind implements the Checker Interface
func (*Checksum) Kind() string {
return "checksum"
}
func (c *Checksum) checksumFilePath() string {
return filepath.Join(c.TempDir, "checksum", normalizeFilename(c.Task))
func (checker *ChecksumChecker) checksumFilePath(t *taskfile.Task) string {
return filepath.Join(checker.tempDir, "checksum", normalizeFilename(t.Name()))
}
var checksumFilenameRegexp = regexp.MustCompile("[^A-z0-9]")
// replaces invalid caracters on filenames with "-"
// replaces invalid characters on filenames with "-"
func normalizeFilename(f string) string {
return checksumFilenameRegexp.ReplaceAllString(f, "-")
}

View File

@@ -1,4 +1,4 @@
package status
package fingerprint
import (
"testing"

View File

@@ -0,0 +1,23 @@
package fingerprint
import "github.com/go-task/task/v3/taskfile"
// NoneChecker is a no-op Checker.
// It will always report that the task is not up-to-date.
type NoneChecker struct{}
func (NoneChecker) IsUpToDate(t *taskfile.Task) (bool, error) {
return false, nil
}
func (NoneChecker) Value(t *taskfile.Task) (interface{}, error) {
return "", nil
}
func (NoneChecker) OnError(t *taskfile.Task) error {
return nil
}
func (NoneChecker) Kind() string {
return "none"
}

View File

@@ -1,24 +1,29 @@
package status
package fingerprint
import (
"os"
"path/filepath"
"time"
"github.com/go-task/task/v3/taskfile"
)
// Timestamp checks if any source change compared with the generated files,
// TimestampChecker checks if any source change compared with the generated files,
// using file modifications timestamps.
type Timestamp struct {
TempDir string
Task string
Dir string
Sources []string
Generates []string
Dry bool
type TimestampChecker struct {
tempDir string
dry bool
}
func NewTimestampChecker(tempDir string, dry bool) *TimestampChecker {
return &TimestampChecker{
tempDir: tempDir,
dry: dry,
}
}
// IsUpToDate implements the Checker interface
func (t *Timestamp) IsUpToDate() (bool, error) {
func (checker *TimestampChecker) IsUpToDate(t *taskfile.Task) (bool, error) {
if len(t.Sources) == 0 {
return false, nil
}
@@ -32,7 +37,7 @@ func (t *Timestamp) IsUpToDate() (bool, error) {
return false, nil
}
timestampFile := t.timestampFilePath()
timestampFile := checker.timestampFilePath(t)
// If the file exists, add the file path to the generates.
// If the generate file is old, the task will be executed.
@@ -41,7 +46,7 @@ func (t *Timestamp) IsUpToDate() (bool, error) {
generates = append(generates, timestampFile)
} else {
// Create the timestamp file for the next execution when the file does not exist.
if !t.Dry {
if !checker.dry {
if err := os.MkdirAll(filepath.Dir(timestampFile), 0o755); err != nil {
return false, err
}
@@ -70,7 +75,7 @@ func (t *Timestamp) IsUpToDate() (bool, error) {
}
// Modify the metadata of the file to the the current time.
if !t.Dry {
if !checker.dry {
if err := os.Chtimes(timestampFile, taskTime, taskTime); err != nil {
return false, err
}
@@ -79,12 +84,12 @@ func (t *Timestamp) IsUpToDate() (bool, error) {
return !shouldUpdate, nil
}
func (t *Timestamp) Kind() string {
func (checker *TimestampChecker) Kind() string {
return "timestamp"
}
// Value implements the Checker Interface
func (t *Timestamp) Value() (interface{}, error) {
func (checker *TimestampChecker) Value(t *taskfile.Task) (interface{}, error) {
sources, err := globs(t.Dir, t.Sources)
if err != nil {
return time.Now(), err
@@ -137,10 +142,10 @@ func anyFileNewerThan(files []string, givenTime time.Time) (bool, error) {
}
// OnError implements the Checker interface
func (*Timestamp) OnError() error {
func (*TimestampChecker) OnError(t *taskfile.Task) error {
return nil
}
func (t *Timestamp) timestampFilePath() string {
return filepath.Join(t.TempDir, "timestamp", normalizeFilename(t.Task))
func (checker *TimestampChecker) timestampFilePath(t *taskfile.Task) string {
return filepath.Join(checker.tempDir, "timestamp", normalizeFilename(t.Task))
}

View File

@@ -0,0 +1,36 @@
package fingerprint
import (
"context"
"github.com/go-task/task/v3/internal/env"
"github.com/go-task/task/v3/internal/execext"
"github.com/go-task/task/v3/internal/logger"
"github.com/go-task/task/v3/taskfile"
)
type StatusChecker struct {
logger *logger.Logger
}
func NewStatusChecker(logger *logger.Logger) StatusCheckable {
return &StatusChecker{
logger: logger,
}
}
func (checker *StatusChecker) IsUpToDate(ctx context.Context, t *taskfile.Task) (bool, error) {
for _, s := range t.Status {
err := execext.RunCommand(ctx, &execext.RunCommandOptions{
Command: s,
Dir: t.Dir,
Env: env.Get(t),
})
if err != nil {
checker.logger.VerboseOutf(logger.Yellow, "task: status command %s exited non-zero: %s", s, err)
return false, nil
}
checker.logger.VerboseOutf(logger.Yellow, "task: status command %s exited zero", s)
}
return true, nil
}

View File

@@ -0,0 +1,132 @@
package fingerprint
import (
"context"
"github.com/go-task/task/v3/internal/logger"
"github.com/go-task/task/v3/taskfile"
)
type (
CheckerOption func(*CheckerConfig)
CheckerConfig struct {
method string
dry bool
tempDir string
logger *logger.Logger
statusChecker StatusCheckable
sourcesChecker SourcesCheckable
}
)
func WithMethod(method string) CheckerOption {
return func(config *CheckerConfig) {
config.method = method
}
}
func WithDry(dry bool) CheckerOption {
return func(config *CheckerConfig) {
config.dry = dry
}
}
func WithTempDir(tempDir string) CheckerOption {
return func(config *CheckerConfig) {
config.tempDir = tempDir
}
}
func WithLogger(logger *logger.Logger) CheckerOption {
return func(config *CheckerConfig) {
config.logger = logger
}
}
func WithStatusChecker(checker StatusCheckable) CheckerOption {
return func(config *CheckerConfig) {
config.statusChecker = checker
}
}
func WithSourcesChecker(checker SourcesCheckable) CheckerOption {
return func(config *CheckerConfig) {
config.sourcesChecker = checker
}
}
func IsTaskUpToDate(
ctx context.Context,
t *taskfile.Task,
opts ...CheckerOption,
) (bool, error) {
var statusUpToDate bool
var sourcesUpToDate bool
var err error
// Default config
config := &CheckerConfig{
method: "none",
tempDir: "",
dry: false,
logger: nil,
statusChecker: nil,
sourcesChecker: nil,
}
// Apply functional options
for _, opt := range opts {
opt(config)
}
// If no status checker was given, set up the default one
if config.statusChecker == nil {
config.statusChecker = NewStatusChecker(config.logger)
}
// If no sources checker was given, set up the default one
if config.sourcesChecker == nil {
config.sourcesChecker, err = NewSourcesChecker(config.method, config.tempDir, config.dry)
if err != nil {
return false, err
}
}
statusIsSet := len(t.Status) != 0
sourcesIsSet := len(t.Sources) != 0
// If status is set, check if it is up-to-date
if statusIsSet {
statusUpToDate, err = config.statusChecker.IsUpToDate(ctx, t)
if err != nil {
return false, err
}
}
// If sources is set, check if they are up-to-date
if sourcesIsSet {
sourcesUpToDate, err = config.sourcesChecker.IsUpToDate(t)
if err != nil {
return false, err
}
}
// If both status and sources are set, the task is up-to-date if both are up-to-date
if statusIsSet && sourcesIsSet {
return statusUpToDate && sourcesUpToDate, nil
}
// If only status is set, the task is up-to-date if the status is up-to-date
if statusIsSet {
return statusUpToDate, nil
}
// If only sources is set, the task is up-to-date if the sources are up-to-date
if sourcesIsSet {
return sourcesUpToDate, nil
}
// If no status or sources are set, the task should always run
// i.e. it is never considered "up-to-date"
return false, nil
}

View File

@@ -0,0 +1,174 @@
package fingerprint
import (
"context"
"testing"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/go-task/task/v3/taskfile"
)
// TruthTable
//
// | Status up-to-date | Sources up-to-date | Task is up-to-date |
// | ----------------- | ------------------ | ------------------ |
// | not set | not set | false |
// | not set | true | true |
// | not set | false | false |
// | true | not set | true |
// | true | true | true |
// | true | false | false |
// | false | not set | false |
// | false | true | false |
// | false | false | false |
func TestIsTaskUpToDate(t *testing.T) {
tests := []struct {
name string
task *taskfile.Task
setupMockStatusChecker func(m *MockStatusCheckable)
setupMockSourcesChecker func(m *MockSourcesCheckable)
expected bool
}{
{
name: "expect FALSE when no status or sources are defined",
task: &taskfile.Task{
Status: nil,
Sources: nil,
},
setupMockStatusChecker: nil,
setupMockSourcesChecker: nil,
expected: false,
},
{
name: "expect TRUE when no status is defined and sources are up-to-date",
task: &taskfile.Task{
Status: nil,
Sources: []string{"sources"},
},
setupMockStatusChecker: nil,
setupMockSourcesChecker: func(m *MockSourcesCheckable) {
m.EXPECT().IsUpToDate(gomock.Any()).Return(true, nil)
},
expected: true,
},
{
name: "expect FALSE when no status is defined and sources are NOT up-to-date",
task: &taskfile.Task{
Status: nil,
Sources: []string{"sources"},
},
setupMockStatusChecker: nil,
setupMockSourcesChecker: func(m *MockSourcesCheckable) {
m.EXPECT().IsUpToDate(gomock.Any()).Return(false, nil)
},
expected: false,
},
{
name: "expect TRUE when status is up-to-date and sources are not defined",
task: &taskfile.Task{
Status: []string{"status"},
Sources: nil,
},
setupMockStatusChecker: func(m *MockStatusCheckable) {
m.EXPECT().IsUpToDate(gomock.Any(), gomock.Any()).Return(true, nil)
},
setupMockSourcesChecker: nil,
expected: true,
},
{
name: "expect TRUE when status and sources are up-to-date",
task: &taskfile.Task{
Status: []string{"status"},
Sources: []string{"sources"},
},
setupMockStatusChecker: func(m *MockStatusCheckable) {
m.EXPECT().IsUpToDate(gomock.Any(), gomock.Any()).Return(true, nil)
},
setupMockSourcesChecker: func(m *MockSourcesCheckable) {
m.EXPECT().IsUpToDate(gomock.Any()).Return(true, nil)
},
expected: true,
},
{
name: "expect FALSE when status is up-to-date, but sources are NOT up-to-date",
task: &taskfile.Task{
Status: []string{"status"},
Sources: []string{"sources"},
},
setupMockStatusChecker: func(m *MockStatusCheckable) {
m.EXPECT().IsUpToDate(gomock.Any(), gomock.Any()).Return(true, nil)
},
setupMockSourcesChecker: func(m *MockSourcesCheckable) {
m.EXPECT().IsUpToDate(gomock.Any()).Return(false, nil)
},
expected: false,
},
{
name: "expect FALSE when status is NOT up-to-date and sources are not defined",
task: &taskfile.Task{
Status: []string{"status"},
Sources: nil,
},
setupMockStatusChecker: func(m *MockStatusCheckable) {
m.EXPECT().IsUpToDate(gomock.Any(), gomock.Any()).Return(false, nil)
},
setupMockSourcesChecker: nil,
expected: false,
},
{
name: "expect FALSE when status is NOT up-to-date, but sources are up-to-date",
task: &taskfile.Task{
Status: []string{"status"},
Sources: []string{"sources"},
},
setupMockStatusChecker: func(m *MockStatusCheckable) {
m.EXPECT().IsUpToDate(gomock.Any(), gomock.Any()).Return(false, nil)
},
setupMockSourcesChecker: func(m *MockSourcesCheckable) {
m.EXPECT().IsUpToDate(gomock.Any()).Return(true, nil)
},
expected: false,
},
{
name: "expect FALSE when status and sources are NOT up-to-date",
task: &taskfile.Task{
Status: []string{"status"},
Sources: []string{"sources"},
},
setupMockStatusChecker: func(m *MockStatusCheckable) {
m.EXPECT().IsUpToDate(gomock.Any(), gomock.Any()).Return(false, nil)
},
setupMockSourcesChecker: func(m *MockSourcesCheckable) {
m.EXPECT().IsUpToDate(gomock.Any()).Return(false, nil)
},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
mockStatusChecker := NewMockStatusCheckable(ctrl)
if tt.setupMockStatusChecker != nil {
tt.setupMockStatusChecker(mockStatusChecker)
}
mockSourcesChecker := NewMockSourcesCheckable(ctrl)
if tt.setupMockSourcesChecker != nil {
tt.setupMockSourcesChecker(mockSourcesChecker)
}
result, err := IsTaskUpToDate(
context.Background(),
tt.task,
WithStatusChecker(mockStatusChecker),
WithSourcesChecker(mockSourcesChecker),
)
require.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}

View File

@@ -1,23 +0,0 @@
package status
// None is a no-op Checker
type None struct{}
// IsUpToDate implements the Checker interface
func (None) IsUpToDate() (bool, error) {
return false, nil
}
// Value implements the Checker interface
func (None) Value() (interface{}, error) {
return "", nil
}
func (None) Kind() string {
return "none"
}
// OnError implements the Checker interface
func (None) OnError() error {
return nil
}

View File

@@ -1,15 +0,0 @@
package status
var (
_ Checker = &Timestamp{}
_ Checker = &Checksum{}
_ Checker = None{}
)
// Checker is an interface that checks if the status is up-to-date
type Checker interface {
IsUpToDate() (bool, error)
Value() (interface{}, error)
OnError() error
Kind() string
}