refactor(engine): allow scripts to register experiments (#860)
See https://github.com/ooni/probe/issues/2216
This commit is contained in:
parent
69602abe8a
commit
6a0ae5c70b
53 changed files with 1438 additions and 1200 deletions
|
|
@ -5,243 +5,46 @@ package engine
|
|||
//
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
|
||||
"github.com/iancoleman/strcase"
|
||||
"github.com/ooni/probe-cli/v3/internal/model"
|
||||
"github.com/ooni/probe-cli/v3/internal/registry"
|
||||
)
|
||||
|
||||
// InputPolicy describes the experiment policy with respect to input. That is
|
||||
// whether it requires input, optionally accepts input, does not want input.
|
||||
type InputPolicy string
|
||||
|
||||
const (
|
||||
// InputOrQueryBackend indicates that the experiment requires
|
||||
// external input to run and that this kind of input is URLs
|
||||
// from the citizenlab/test-lists repository. If this input
|
||||
// not provided to the experiment, then the code that runs the
|
||||
// experiment is supposed to fetch from URLs from OONI's backends.
|
||||
InputOrQueryBackend = InputPolicy("or_query_backend")
|
||||
|
||||
// InputStrictlyRequired indicates that the experiment
|
||||
// requires input and we currently don't have an API for
|
||||
// fetching such input. Therefore, either the user specifies
|
||||
// input or the experiment will fail for the lack of input.
|
||||
InputStrictlyRequired = InputPolicy("strictly_required")
|
||||
|
||||
// InputOptional indicates that the experiment handles input,
|
||||
// if any; otherwise it fetchs input/uses a default.
|
||||
InputOptional = InputPolicy("optional")
|
||||
|
||||
// InputNone indicates that the experiment does not want any
|
||||
// input and ignores the input if provided with it.
|
||||
InputNone = InputPolicy("none")
|
||||
|
||||
// We gather input from StaticInput and SourceFiles. If there is
|
||||
// input, we return it. Otherwise, we return an internal static
|
||||
// list of inputs to be used with this experiment.
|
||||
InputOrStaticDefault = InputPolicy("or_static_default")
|
||||
)
|
||||
|
||||
// ExperimentBuilder builds an experiment.
|
||||
type ExperimentBuilder interface {
|
||||
// Interruptible tells you whether this is an interruptible experiment. This kind
|
||||
// of experiments (e.g. ndt7) may be interrupted mid way.
|
||||
Interruptible() bool
|
||||
|
||||
// InputPolicy returns the experiment input policy.
|
||||
InputPolicy() InputPolicy
|
||||
|
||||
// Options returns information about the experiment's options.
|
||||
Options() (map[string]OptionInfo, error)
|
||||
|
||||
// SetOptionAny sets an option whose value is an any value. We will use reasonable
|
||||
// heuristics to convert the any value to the proper type of the field whose name is
|
||||
// contained by the key variable. If we cannot convert the provided any value to
|
||||
// the proper type, then this function returns an error.
|
||||
SetOptionAny(key string, value any) error
|
||||
|
||||
// SetOptionsAny sets options from a map[string]any. See the documentation of
|
||||
// the SetOptionAny method for more information.
|
||||
SetOptionsAny(options map[string]any) error
|
||||
|
||||
// SetCallbacks sets the experiment's interactive callbacks.
|
||||
SetCallbacks(callbacks model.ExperimentCallbacks)
|
||||
|
||||
// NewExperiment creates the experiment instance.
|
||||
NewExperiment() Experiment
|
||||
}
|
||||
|
||||
// experimentBuilder implements ExperimentBuilder.
|
||||
//
|
||||
// This type is now just a tiny wrapper around registry.Factory.
|
||||
type experimentBuilder struct {
|
||||
// build is the constructor that build an experiment with the given config.
|
||||
build func(config interface{}) *experiment
|
||||
factory *registry.Factory
|
||||
|
||||
// callbacks contains callbacks for the new experiment.
|
||||
callbacks model.ExperimentCallbacks
|
||||
|
||||
// config contains the experiment's config.
|
||||
config interface{}
|
||||
|
||||
// inputPolicy contains the experiment's InputPolicy.
|
||||
inputPolicy InputPolicy
|
||||
|
||||
// interruptible indicates whether the experiment is interruptible.
|
||||
interruptible bool
|
||||
// session is the session
|
||||
session *Session
|
||||
}
|
||||
|
||||
// Interruptible implements ExperimentBuilder.Interruptible.
|
||||
func (b *experimentBuilder) Interruptible() bool {
|
||||
return b.interruptible
|
||||
return b.factory.Interruptible()
|
||||
}
|
||||
|
||||
// InputPolicy implements ExperimentBuilder.InputPolicy.
|
||||
func (b *experimentBuilder) InputPolicy() InputPolicy {
|
||||
return b.inputPolicy
|
||||
func (b *experimentBuilder) InputPolicy() model.InputPolicy {
|
||||
return b.factory.InputPolicy()
|
||||
}
|
||||
|
||||
// OptionInfo contains info about an option.
|
||||
type OptionInfo struct {
|
||||
// Doc contains the documentation.
|
||||
Doc string
|
||||
|
||||
// Type contains the type.
|
||||
Type string
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrConfigIsNotAStructPointer indicates we expected a pointer to struct.
|
||||
ErrConfigIsNotAStructPointer = errors.New("config is not a struct pointer")
|
||||
|
||||
// ErrNoSuchField indicates there's no field with the given name.
|
||||
ErrNoSuchField = errors.New("no such field")
|
||||
|
||||
// ErrCannotSetIntegerOption means SetOptionAny couldn't set an integer option.
|
||||
ErrCannotSetIntegerOption = errors.New("cannot set integer option")
|
||||
|
||||
// ErrInvalidStringRepresentationOfBool indicates the string you passed
|
||||
// to SetOptionaAny is not a valid string representation of a bool.
|
||||
ErrInvalidStringRepresentationOfBool = errors.New("invalid string representation of bool")
|
||||
|
||||
// ErrCannotSetBoolOption means SetOptionAny couldn't set a bool option.
|
||||
ErrCannotSetBoolOption = errors.New("cannot set bool option")
|
||||
|
||||
// ErrCannotSetStringOption means SetOptionAny couldn't set a string option.
|
||||
ErrCannotSetStringOption = errors.New("cannot set string option")
|
||||
|
||||
// ErrUnsupportedOptionType means we don't support the type passed to
|
||||
// the SetOptionAny method as an opaque any type.
|
||||
ErrUnsupportedOptionType = errors.New("unsupported option type")
|
||||
)
|
||||
|
||||
// Options implements ExperimentBuilder.Options.
|
||||
func (b *experimentBuilder) Options() (map[string]OptionInfo, error) {
|
||||
result := make(map[string]OptionInfo)
|
||||
ptrinfo := reflect.ValueOf(b.config)
|
||||
if ptrinfo.Kind() != reflect.Ptr {
|
||||
return nil, ErrConfigIsNotAStructPointer
|
||||
}
|
||||
structinfo := ptrinfo.Elem().Type()
|
||||
if structinfo.Kind() != reflect.Struct {
|
||||
return nil, ErrConfigIsNotAStructPointer
|
||||
}
|
||||
for i := 0; i < structinfo.NumField(); i++ {
|
||||
field := structinfo.Field(i)
|
||||
result[field.Name] = OptionInfo{
|
||||
Doc: field.Tag.Get("ooni"),
|
||||
Type: field.Type.String(),
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// setOptionBool sets a bool option.
|
||||
func (b *experimentBuilder) setOptionBool(field reflect.Value, value any) error {
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
field.SetBool(v)
|
||||
return nil
|
||||
case string:
|
||||
if v != "true" && v != "false" {
|
||||
return fmt.Errorf("%w: %s", ErrInvalidStringRepresentationOfBool, v)
|
||||
}
|
||||
field.SetBool(v == "true")
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("%w from a value of type %T", ErrCannotSetBoolOption, value)
|
||||
}
|
||||
}
|
||||
|
||||
// setOptionInt sets an int option
|
||||
func (b *experimentBuilder) setOptionInt(field reflect.Value, value any) error {
|
||||
switch v := value.(type) {
|
||||
case int64:
|
||||
field.SetInt(v)
|
||||
return nil
|
||||
case int32:
|
||||
field.SetInt(int64(v))
|
||||
return nil
|
||||
case int16:
|
||||
field.SetInt(int64(v))
|
||||
return nil
|
||||
case int8:
|
||||
field.SetInt(int64(v))
|
||||
return nil
|
||||
case int:
|
||||
field.SetInt(int64(v))
|
||||
return nil
|
||||
case string:
|
||||
number, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", ErrCannotSetIntegerOption, err.Error())
|
||||
}
|
||||
field.SetInt(number)
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("%w from a value of type %T", ErrCannotSetIntegerOption, value)
|
||||
}
|
||||
}
|
||||
|
||||
// setOptionString sets a string option
|
||||
func (b *experimentBuilder) setOptionString(field reflect.Value, value any) error {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
field.SetString(v)
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("%w from a value of type %T", ErrCannotSetStringOption, value)
|
||||
}
|
||||
func (b *experimentBuilder) Options() (map[string]model.ExperimentOptionInfo, error) {
|
||||
return b.factory.Options()
|
||||
}
|
||||
|
||||
// SetOptionAny implements ExperimentBuilder.SetOptionAny.
|
||||
func (b *experimentBuilder) SetOptionAny(key string, value any) error {
|
||||
field, err := b.fieldbyname(b.config, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch field.Kind() {
|
||||
case reflect.Int64:
|
||||
return b.setOptionInt(field, value)
|
||||
case reflect.Bool:
|
||||
return b.setOptionBool(field, value)
|
||||
case reflect.String:
|
||||
return b.setOptionString(field, value)
|
||||
default:
|
||||
return fmt.Errorf("%w: %T", ErrUnsupportedOptionType, value)
|
||||
}
|
||||
return b.factory.SetOptionAny(key, value)
|
||||
}
|
||||
|
||||
// SetOptionsAny implements ExperimentBuilder.SetOptionsAny.
|
||||
func (b *experimentBuilder) SetOptionsAny(options map[string]any) error {
|
||||
for key, value := range options {
|
||||
if err := b.SetOptionAny(key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return b.factory.SetOptionsAny(options)
|
||||
}
|
||||
|
||||
// SetCallbacks implements ExperimentBuilder.SetCallbacks.
|
||||
|
|
@ -249,57 +52,24 @@ func (b *experimentBuilder) SetCallbacks(callbacks model.ExperimentCallbacks) {
|
|||
b.callbacks = callbacks
|
||||
}
|
||||
|
||||
// fieldbyname return v's field whose name is equal to the given key.
|
||||
func (b *experimentBuilder) fieldbyname(v interface{}, key string) (reflect.Value, error) {
|
||||
// See https://stackoverflow.com/a/6396678/4354461
|
||||
ptrinfo := reflect.ValueOf(v)
|
||||
if ptrinfo.Kind() != reflect.Ptr {
|
||||
return reflect.Value{}, fmt.Errorf("%w but a %T", ErrConfigIsNotAStructPointer, v)
|
||||
}
|
||||
structinfo := ptrinfo.Elem()
|
||||
if structinfo.Kind() != reflect.Struct {
|
||||
return reflect.Value{}, fmt.Errorf("%w but a %T", ErrConfigIsNotAStructPointer, v)
|
||||
}
|
||||
field := structinfo.FieldByName(key)
|
||||
if !field.IsValid() || !field.CanSet() {
|
||||
return reflect.Value{}, fmt.Errorf("%w: %s", ErrNoSuchField, key)
|
||||
}
|
||||
return field, nil
|
||||
}
|
||||
|
||||
// NewExperiment creates the experiment
|
||||
func (b *experimentBuilder) NewExperiment() Experiment {
|
||||
experiment := b.build(b.config)
|
||||
func (b *experimentBuilder) NewExperiment() model.Experiment {
|
||||
measurer := b.factory.NewExperimentMeasurer()
|
||||
experiment := newExperiment(b.session, measurer)
|
||||
experiment.callbacks = b.callbacks
|
||||
return experiment
|
||||
}
|
||||
|
||||
// canonicalizeExperimentName allows code to provide experiment names
|
||||
// in a more flexible way, where we have aliases.
|
||||
//
|
||||
// Because we allow for uppercase experiment names for backwards
|
||||
// compatibility with MK, we need to add some exceptions here when
|
||||
// mapping (e.g., DNSCheck => dnscheck).
|
||||
func canonicalizeExperimentName(name string) string {
|
||||
switch name = strcase.ToSnake(name); name {
|
||||
case "ndt_7":
|
||||
name = "ndt" // since 2020-03-18, we use ndt7 to implement ndt by default
|
||||
case "dns_check":
|
||||
name = "dnscheck"
|
||||
case "stun_reachability":
|
||||
name = "stunreachability"
|
||||
default:
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// newExperimentBuilder creates a new experimentBuilder instance.
|
||||
func newExperimentBuilder(session *Session, name string) (*experimentBuilder, error) {
|
||||
factory := experimentsByName[canonicalizeExperimentName(name)]
|
||||
if factory == nil {
|
||||
return nil, fmt.Errorf("no such experiment: %s", name)
|
||||
factory, err := registry.NewFactory(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
builder := &experimentBuilder{
|
||||
factory: factory,
|
||||
callbacks: model.NewPrinterCallbacks(session.Logger()),
|
||||
session: session,
|
||||
}
|
||||
builder := factory(session)
|
||||
builder.callbacks = model.NewPrinterCallbacks(session.Logger())
|
||||
return builder, nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue