086ae43b15
This diff refactors how we set options for experiments to accept in input an any value or a map[string]any, depending on which method we choose to actually set options. There should be no functional change, except that now we're not guessing the type and then attempting to set the value of the selected field: now, instead, we match the provided type and the field's type as part of the same function (i.e., SetOptionAny). This diff is functional to https://github.com/ooni/probe/issues/2184, because it will allow us to load options from a map[string]any, which will be part of the OONI Run v2 JSON descriptor. If we didn't apply this change, we would only have been to set options from a map[string]string, which is good enough as a solution for the CLI but is definitely clumsy when you have to write stuff like: ```JSON { "options": { "HTTP3Enabled": "true" } } ``` when you could instead more naturally write: ```JSON { "options": { "HTTP3Enabled": true } } ```
267 lines
8.3 KiB
Go
267 lines
8.3 KiB
Go
package engine
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"strconv"
|
|
|
|
"github.com/iancoleman/strcase"
|
|
"github.com/ooni/probe-cli/v3/internal/model"
|
|
)
|
|
|
|
// 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 is an experiment builder.
|
|
type ExperimentBuilder struct {
|
|
build func(interface{}) *Experiment
|
|
callbacks model.ExperimentCallbacks
|
|
config interface{}
|
|
inputPolicy InputPolicy
|
|
interruptible bool
|
|
}
|
|
|
|
// Interruptible tells you whether this is an interruptible experiment. This kind
|
|
// of experiments (e.g. ndt7) may be interrupted mid way.
|
|
func (b *ExperimentBuilder) Interruptible() bool {
|
|
return b.interruptible
|
|
}
|
|
|
|
// InputPolicy returns the experiment input policy
|
|
func (b *ExperimentBuilder) InputPolicy() InputPolicy {
|
|
return b.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 returns info about all 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)
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
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)
|
|
}
|
|
}
|
|
|
|
// SetOptionsAny sets options from a map[string]any. See the documentation of
|
|
// the SetOptionAny function for more information.
|
|
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
|
|
}
|
|
|
|
// SetCallbacks sets the interactive callbacks
|
|
func (b *ExperimentBuilder) SetCallbacks(callbacks model.ExperimentCallbacks) {
|
|
b.callbacks = callbacks
|
|
}
|
|
|
|
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)
|
|
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
|
|
}
|
|
|
|
func newExperimentBuilder(session *Session, name string) (*ExperimentBuilder, error) {
|
|
factory := experimentsByName[canonicalizeExperimentName(name)]
|
|
if factory == nil {
|
|
return nil, fmt.Errorf("no such experiment: %s", name)
|
|
}
|
|
builder := factory(session)
|
|
builder.callbacks = model.NewPrinterCallbacks(session.Logger())
|
|
return builder, nil
|
|
}
|