Simone Basso c264f61536
feat(ooniprobe): introduce websites_max_runtime (#273)
We cannot control anymore the maximum number of URLs using the API
because now we are using check-in, that has no such limit.

We could theoretically clamp the number of URLs to measure after
the call to check-in, and still honour the setting.

Yet, the right thing to do seems to introduce a max runtime variable
because that is what desktop and mobile do.

Thus, introduce code that warns the user about the change in the
settings, should they have set the URL limit to nonzero.

We are going to do a best effort conversion from the URL limit to
the maximum runtime for the rest of 2021.

Since then, we will silently ignore the URL limit.

This work is part of https://github.com/ooni/probe/issues/1299.
2021-03-30 11:16:12 +02:00

226 lines
7.5 KiB
Go

package nettests
import (
"database/sql"
"fmt"
"time"
"github.com/apex/log"
"github.com/fatih/color"
"github.com/ooni/probe-cli/v3/cmd/ooniprobe/internal/database"
"github.com/ooni/probe-cli/v3/cmd/ooniprobe/internal/ooni"
"github.com/ooni/probe-cli/v3/cmd/ooniprobe/internal/output"
engine "github.com/ooni/probe-cli/v3/internal/engine"
"github.com/ooni/probe-cli/v3/internal/engine/model"
"github.com/pkg/errors"
)
// Nettest interface. Every Nettest should implement this.
type Nettest interface {
Run(*Controller) error
}
// NewController creates a nettest controller
func NewController(
nt Nettest, probe *ooni.Probe, res *database.Result, sess *engine.Session) *Controller {
return &Controller{
Probe: probe,
nt: nt,
res: res,
Session: sess,
}
}
// Controller is passed to the run method of every Nettest
// each nettest instance has one controller
type Controller struct {
Probe *ooni.Probe
Session *engine.Session
res *database.Result
nt Nettest
ntCount int
ntIndex int
ntStartTime time.Time // used to calculate the eta
msmts map[int64]*database.Measurement
inputIdxMap map[int64]int64 // Used to map mk idx to database id
// InputFiles optionally contains the names of the input
// files to read inputs from (only for nettests that take
// inputs, of course)
InputFiles []string
// Inputs contains inputs to be tested. These are specified
// using the command line using the --input flag.
Inputs []string
// numInputs is the total number of inputs
numInputs int
// curInputIdx is the current input index
curInputIdx int
}
// SetInputIdxMap is used to set the mapping of index into input. This mapping
// is used to reference, for example, a particular URL based on the index inside
// of the input list and the index of it in the database.
func (c *Controller) SetInputIdxMap(inputIdxMap map[int64]int64) error {
c.inputIdxMap = inputIdxMap
return nil
}
// SetNettestIndex is used to set the current nettest index and total nettest
// count to compute a different progress percentage.
func (c *Controller) SetNettestIndex(i, n int) {
c.ntCount = n
c.ntIndex = i
}
// Run runs the selected nettest using the related experiment
// with the specified inputs.
//
// This function will continue to run in most cases but will
// immediately halt if something's wrong with the file system.
func (c *Controller) Run(builder *engine.ExperimentBuilder, inputs []string) error {
// This will configure the controller as handler for the callbacks
// called by ooni/probe-engine/experiment.Experiment.
builder.SetCallbacks(model.ExperimentCallbacks(c))
c.numInputs = len(inputs)
exp := builder.NewExperiment()
defer func() {
c.res.DataUsageDown += exp.KibiBytesReceived()
c.res.DataUsageUp += exp.KibiBytesSent()
}()
c.msmts = make(map[int64]*database.Measurement)
// These values are shared by every measurement
var reportID sql.NullString
resultID := c.res.ID
log.Debug(color.RedString("status.queued"))
log.Debug(color.RedString("status.started"))
if c.Probe.Config().Sharing.UploadResults {
if err := exp.OpenReport(); err != nil {
log.Debugf(
"%s: %s", color.RedString("failure.report_create"), err.Error(),
)
} else {
log.Debugf(color.RedString("status.report_create"))
reportID = sql.NullString{String: exp.ReportID(), Valid: true}
}
}
maxRuntime := time.Duration(c.Probe.Config().Nettests.WebsitesMaxRuntime) * time.Second
start := time.Now()
c.ntStartTime = start
for idx, input := range inputs {
if c.Probe.IsTerminated() {
log.Info("user requested us to terminate using Ctrl-C")
break
}
if maxRuntime > 0 && time.Since(start) > maxRuntime {
log.Info("exceeded maximum runtime")
break
}
c.curInputIdx = idx // allow for precise progress
idx64 := int64(idx)
log.Debug(color.RedString("status.measurement_start"))
var urlID sql.NullInt64
if c.inputIdxMap != nil {
urlID = sql.NullInt64{Int64: c.inputIdxMap[idx64], Valid: true}
}
msmt, err := database.CreateMeasurement(
c.Probe.DB(), reportID, exp.Name(), c.res.MeasurementDir, idx, resultID, urlID,
)
if err != nil {
return errors.Wrap(err, "failed to create measurement")
}
c.msmts[idx64] = msmt
if input != "" {
c.OnProgress(0, fmt.Sprintf("processing input: %s", input))
}
measurement, err := exp.Measure(input)
if err != nil {
log.WithError(err).Debug(color.RedString("failure.measurement"))
if err := c.msmts[idx64].Failed(c.Probe.DB(), err.Error()); err != nil {
return errors.Wrap(err, "failed to mark measurement as failed")
}
// Even with a failed measurement, we want to continue. We want to
// record and submit the information we have. Saving the information
// is useful for local inspection. Submitting it is useful to us to
// understand what went wrong (censorship? bug? anomaly?).
}
saveToDisk := true
if c.Probe.Config().Sharing.UploadResults {
// Implementation note: SubmitMeasurement will fail here if we did fail
// to open the report but we still want to continue. There will be a
// bit of a spew in the logs, perhaps, but stopping seems less efficient.
if err := exp.SubmitAndUpdateMeasurement(measurement); err != nil {
log.Debug(color.RedString("failure.measurement_submission"))
if err := c.msmts[idx64].UploadFailed(c.Probe.DB(), err.Error()); err != nil {
return errors.Wrap(err, "failed to mark upload as failed")
}
} else if err := c.msmts[idx64].UploadSucceeded(c.Probe.DB()); err != nil {
return errors.Wrap(err, "failed to mark upload as succeeded")
} else {
// Everything went OK, don't save to disk
saveToDisk = false
}
}
// We only save the measurement to disk if we failed to upload the measurement
if saveToDisk == true {
if err := exp.SaveMeasurement(measurement, msmt.MeasurementFilePath.String); err != nil {
return errors.Wrap(err, "failed to save measurement on disk")
}
}
if err := c.msmts[idx64].Done(c.Probe.DB()); err != nil {
return errors.Wrap(err, "failed to mark measurement as done")
}
// We're not sure whether it's enough to log the error or we should
// instead also mark the measurement as failed. Strictly speaking this
// is an inconsistency between the code that generate the measurement
// and the code that process the measurement. We do have some data
// but we're not gonna have a summary. To be reconsidered.
tk, err := exp.GetSummaryKeys(measurement)
if err != nil {
log.WithError(err).Error("failed to obtain testKeys")
continue
}
log.Debugf("Fetching: %d %v", idx, c.msmts[idx64])
if err := database.AddTestKeys(c.Probe.DB(), c.msmts[idx64], tk); err != nil {
return errors.Wrap(err, "failed to add test keys to summary")
}
}
log.Debugf("status.end")
return nil
}
// OnProgress should be called when a new progress event is available.
func (c *Controller) OnProgress(perc float64, msg string) {
log.Debugf("OnProgress: %f - %s", perc, msg)
var eta float64
eta = -1.0
if c.numInputs > 1 {
// make the percentage relative to the current input over all inputs
floor := (float64(c.curInputIdx) / float64(c.numInputs))
step := 1.0 / float64(c.numInputs)
perc = floor + perc*step
if c.curInputIdx > 0 {
eta = (time.Now().Sub(c.ntStartTime).Seconds() / float64(c.curInputIdx)) * float64(c.numInputs-c.curInputIdx)
}
}
if c.ntCount > 0 {
// make the percentage relative to the current nettest over all nettests
perc = float64(c.ntIndex)/float64(c.ntCount) + perc/float64(c.ntCount)
}
key := fmt.Sprintf("%T", c.nt)
output.Progress(key, perc, eta, msg)
}