ooni-probe-cli/internal/oonirun/v2.go
Simone Basso c420c8bb29
feat(miniooni): run local oonirun v2 descriptor (#966)
We introduce the -f, --input-file FILE option with which we
are able to run an OONI Run v2 descriptor stored locally.

In this running mode, there are no checks related to whether the
descriptor has changed, since we're dealing with a local file.

Closes https://github.com/ooni/probe/issues/2328
2022-09-29 11:43:23 +02:00

258 lines
8.6 KiB
Go

package oonirun
//
// OONI Run v2 implementation
//
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/hexops/gotextdiff"
"github.com/hexops/gotextdiff/myers"
"github.com/hexops/gotextdiff/span"
"github.com/ooni/probe-cli/v3/internal/atomicx"
"github.com/ooni/probe-cli/v3/internal/httpx"
"github.com/ooni/probe-cli/v3/internal/kvstore"
"github.com/ooni/probe-cli/v3/internal/model"
"github.com/ooni/probe-cli/v3/internal/runtimex"
)
var (
// v2CountEmptyNettestNames counts the number of cases in which we have been
// given an empty nettest name, which is useful for testing.
v2CountEmptyNettestNames = &atomicx.Int64{}
// v2CountFailedExperiments countes the number of failed experiments
// and is useful when testing this package
v2CountFailedExperiments = &atomicx.Int64{}
)
// V2Descriptor describes a list of nettests to run together.
type V2Descriptor struct {
// Name is the name of this descriptor.
Name string `json:"name"`
// Description contains a long description.
Description string `json:"description"`
// Author contains the author's name.
Author string `json:"author"`
// Nettests contains the list of nettests to run.
Nettests []V2Nettest `json:"nettests"`
}
// V2Nettest specifies how a nettest should run.
type V2Nettest struct {
// Inputs contains inputs for the experiment.
Inputs []string `json:"inputs"`
// Options contains the experiment options. Any option name starting with
// `Safe` will be available for the experiment run, but omitted from
// the serialized Measurement that the experiment builder will submit
// to the OONI backend.
Options map[string]any `json:"options"`
// TestName contains the nettest name.
TestName string `json:"test_name"`
}
// ErrHTTPRequestFailed indicates that an HTTP request failed.
var ErrHTTPRequestFailed = errors.New("oonirun: HTTP request failed")
// getV2DescriptorFromHTTPSURL GETs a v2Descriptor instance from
// a static URL (e.g., from a GitHub repo or from a Gist).
func getV2DescriptorFromHTTPSURL(ctx context.Context, client model.HTTPClient,
logger model.Logger, URL string) (*V2Descriptor, error) {
template := httpx.APIClientTemplate{
Accept: "",
Authorization: "",
BaseURL: URL,
HTTPClient: client,
Host: "",
LogBody: true,
Logger: logger,
UserAgent: model.HTTPHeaderUserAgent,
}
var desc V2Descriptor
if err := template.Build().GetJSON(ctx, "", &desc); err != nil {
return nil, err
}
return &desc, nil
}
// v2DescriptorCache contains all the known v2Descriptor entries.
type v2DescriptorCache struct {
// Entries contains all the cached descriptors.
Entries map[string]*V2Descriptor
}
// v2DescriptorCacheKey is the name of the kvstore2 entry keeping
// information about already known v2Descriptor instances.
const v2DescriptorCacheKey = "oonirun-v2.state"
// v2DescriptorCacheLoad loads the v2DescriptorCache.
func v2DescriptorCacheLoad(fsstore model.KeyValueStore) (*v2DescriptorCache, error) {
data, err := fsstore.Get(v2DescriptorCacheKey)
if err != nil {
if errors.Is(err, kvstore.ErrNoSuchKey) {
cache := &v2DescriptorCache{
Entries: make(map[string]*V2Descriptor),
}
return cache, nil
}
return nil, err
}
var cache v2DescriptorCache
if err := json.Unmarshal(data, &cache); err != nil {
return nil, err
}
if cache.Entries == nil {
cache.Entries = make(map[string]*V2Descriptor)
}
return &cache, nil
}
// PullChangesWithoutSideEffects fetches v2Descriptor changes.
//
// This function DOES NOT change the state of the cache. It just returns to
// the caller what changed for a given entry. It is up-to-the-caller to choose
// what to do in case there are changes depending on the CLI flags.
//
// Arguments:
//
// - ctx is the context for deadline/cancellation;
//
// - client is the HTTPClient to use;
//
// - URL is the URL from which to download/update the OONIRun v2Descriptor.
//
// Return values:
//
// - oldValue is the old v2Descriptor, which may be nil;
//
// - newValue is the new v2Descriptor, which may be nil;
//
// - err is the error that occurred, or nil in case of success.
func (cache *v2DescriptorCache) PullChangesWithoutSideEffects(
ctx context.Context, client model.HTTPClient, logger model.Logger,
URL string) (oldValue, newValue *V2Descriptor, err error) {
oldValue = cache.Entries[URL]
newValue, err = getV2DescriptorFromHTTPSURL(ctx, client, logger, URL)
return
}
// Update updates the given cache entry and writes back onto the disk.
//
// Note: this method modifies cache and is not safe for concurrent usage.
func (cache *v2DescriptorCache) Update(
fsstore model.KeyValueStore, URL string, entry *V2Descriptor) error {
cache.Entries[URL] = entry
data, err := json.Marshal(cache)
runtimex.PanicOnError(err, "json.Marshal failed")
return fsstore.Set(v2DescriptorCacheKey, data)
}
// ErrNilDescriptor indicates that we have been passed a descriptor that is nil.
var ErrNilDescriptor = errors.New("oonirun: descriptor is nil")
// V2MeasureDescriptor performs the measurement or measurements
// described by the given list of v2Descriptor.
func V2MeasureDescriptor(ctx context.Context, config *LinkConfig, desc *V2Descriptor) error {
if desc == nil {
// Note: we have a test checking that we can handle a nil
// descriptor, yet adding also this extra safety net feels
// more robust in terms of the implementation.
return ErrNilDescriptor
}
logger := config.Session.Logger()
for _, nettest := range desc.Nettests {
if nettest.TestName == "" {
logger.Warn("oonirun: nettest name cannot be empty")
v2CountEmptyNettestNames.Add(1)
continue
}
exp := &Experiment{
Annotations: config.Annotations,
ExtraOptions: nettest.Options,
Inputs: nettest.Inputs,
InputFilePaths: nil,
MaxRuntime: config.MaxRuntime,
Name: nettest.TestName,
NoCollector: config.NoCollector,
NoJSON: config.NoJSON,
Random: config.Random,
ReportFile: config.ReportFile,
Session: config.Session,
newExperimentBuilderFn: nil,
newInputLoaderFn: nil,
newSubmitterFn: nil,
newSaverFn: nil,
newInputProcessorFn: nil,
}
if err := exp.Run(ctx); err != nil {
logger.Warnf("cannot run experiment: %s", err.Error())
v2CountFailedExperiments.Add(1)
continue
}
}
return nil
}
// ErrNeedToAcceptChanges indicates that the user needs to accept
// changes (i.e., a new or modified set of descriptors) before
// we can actually run this set of descriptors.
var ErrNeedToAcceptChanges = errors.New("oonirun: need to accept changes")
// v2DescriptorDiff shows what changed between the old and the new descriptors.
func v2DescriptorDiff(oldValue, newValue *V2Descriptor, URL string) string {
oldData, err := json.MarshalIndent(oldValue, "", " ")
runtimex.PanicOnError(err, "json.MarshalIndent failed unexpectedly")
newData, err := json.MarshalIndent(newValue, "", " ")
runtimex.PanicOnError(err, "json.MarshalIndent failed unexpectedly")
oldString, newString := string(oldData)+"\n", string(newData)+"\n"
oldFile := "OLD " + URL
newFile := "NEW " + URL
edits := myers.ComputeEdits(span.URIFromPath(oldFile), oldString, newString)
return fmt.Sprint(gotextdiff.ToUnified(oldFile, newFile, oldString, edits))
}
// v2MeasureHTTPS performs a measurement using an HTTPS v2 OONI Run URL
// and returns whether performing this measurement failed.
//
// This function maintains an on-disk cache that tracks the status of
// OONI Run v2 links. If there are any changes and the user has not
// provided config.AcceptChanges, this function will log what has changed
// and will return with an ErrNeedToAcceptChanges error.
//
// In such a case, the caller SHOULD print additional information
// explaining how to accept changes and then SHOULD exit 1 or similar.
func v2MeasureHTTPS(ctx context.Context, config *LinkConfig, URL string) error {
logger := config.Session.Logger()
logger.Infof("oonirun/v2: running %s", URL)
cache, err := v2DescriptorCacheLoad(config.KVStore)
if err != nil {
return err
}
clnt := config.Session.DefaultHTTPClient()
oldValue, newValue, err := cache.PullChangesWithoutSideEffects(ctx, clnt, logger, URL)
if err != nil {
return err
}
diff := v2DescriptorDiff(oldValue, newValue, URL)
if !config.AcceptChanges && diff != "" {
logger.Warnf("oonirun: %s changed as follows:\n\n%s", URL, diff)
logger.Warnf("oonirun: we are not going to run this link until you accept changes")
return ErrNeedToAcceptChanges
}
if diff != "" {
if err := cache.Update(config.KVStore, URL, newValue); err != nil {
return err
}
}
return V2MeasureDescriptor(ctx, config, newValue) // handles nil newValue gracefully
}