refactor: start building an Android package (#205)

* refactor: start building an Android package

Part of https://github.com/ooni/probe/issues/1335.

This seems also a good moment to move some packages out of the
engine, e.g., oonimkall. This package, for example, is a consumer
of the engine, so it makes sense it's not _inside_ it.

* fix: committed some stuff I didn't need to commit

* fix: oonimkall needs to be public to build

The side effect is that we will probably need to bump the major
version number every time we change one of these APIs.

(We can also of course choose to violate the basic guidelines of Go
software, but I believe this is bad form.)

I have no problem in bumping the major quite frequently and in
any case this monorepo solution is convinving me more than continuing
to keep a split between engine and cli. The need to embed assets to
make the probe more reliable trumps the negative effects of having to
~frequently bump major because we expose a public API.

* fix: let's not forget about libooniffi

Honestly, I don't know what to do with this library. I added it
to provide a drop in replacement for MK but I have no idea whether
it's used and useful. I would not feel comfortable exposing it,
unlike oonimkall, since we're not using it.

It may be that the right thing to do here is just to delete the
package and reduce the amount of code we're maintaining?

* woops, we're still missing the publish android script

* fix(publish-android.bash): add proper API key

* ouch fix another place where the name changed
This commit is contained in:
Simone Basso
2021-02-03 10:51:14 +01:00
committed by GitHub
parent 6714b79f97
commit 99b28c1d95
61 changed files with 40 additions and 39 deletions
+20
View File
@@ -0,0 +1,20 @@
# Package github.com/ooni/probe-cli/pkg/oonimkall
Package oonimkall implements APIs used by OONI mobile apps. We
expose these APIs to mobile apps using gomobile.
We expose two APIs: the task API, which is derived from the
API originally exposed by Measurement Kit, and the session API,
which is a Go API that mobile apps can use via `gomobile`.
This package is named oonimkall because it contains a partial
reimplementation of the mkall API implemented by Measurement Kit
in, e.g., [mkall-ios](https://github.com/measurement-kit/mkall-ios).
The basic tenet of the task API is that you define an experiment
task you wanna run using a JSON, then you start a task for it, and
you receive events as serialized JSONs. In addition to this
functionality, we also include extra APIs used by OONI mobile.
The basic tenet of the session API is that you create an instance
of `Session` and use it to perform the operations you need.
+377
View File
@@ -0,0 +1,377 @@
package oonimkall
import (
"context"
"encoding/json"
"errors"
"fmt"
"runtime"
"sync"
engine "github.com/ooni/probe-cli/v3/internal/engine"
"github.com/ooni/probe-cli/v3/internal/engine/atomicx"
"github.com/ooni/probe-cli/v3/internal/engine/runtimex"
"github.com/ooni/probe-cli/v3/internal/engine/model"
"github.com/ooni/probe-cli/v3/internal/engine/probeservices"
)
// AtomicInt64 allows us to export atomicx.Int64 variables to
// mobile libraries so we can use them in testing.
type AtomicInt64 struct {
*atomicx.Int64
}
// The following two variables contain metrics pertaining to the number
// of Sessions and Contexts that are currently being used.
var (
ActiveSessions = &AtomicInt64{atomicx.NewInt64()}
ActiveContexts = &AtomicInt64{atomicx.NewInt64()}
)
// Logger is the logger used by a Session. You should implement a class
// compatible with this interface in Java/ObjC and then save a reference
// to this instance in the SessionConfig object. All log messages that
// the Session will generate will be routed to this Logger.
type Logger interface {
Debug(msg string)
Info(msg string)
Warn(msg string)
}
// SessionConfig contains configuration for a Session. You should
// fill all the mandatory fields and could also optionally fill some of
// the optional fields. Then pass this struct to NewSession.
type SessionConfig struct {
// AssetsDir is the mandatory directory where to store assets
// required by a Session, e.g. MaxMind DB files.
AssetsDir string
// Logger is the optional logger that will receive all the
// log messages generated by a Session. If this field is nil
// then the session will not emit any log message.
Logger Logger
// ProbeServicesURL allows you to optionally force the
// usage of an alternative probe service instance. This setting
// should only be used for implementing integration tests.
ProbeServicesURL string
// SoftwareName is the mandatory name of the application
// that will be using the new Session.
SoftwareName string
// SoftwareVersion is the mandatory version of the application
// that will be using the new Session.
SoftwareVersion string
// StateDir is the mandatory directory where to store state
// information required by a Session.
StateDir string
// TempDir is the mandatory directory where the Session shall
// store temporary files. Among other tasks, Session.Close will
// remove any temporary file created within this Session.
TempDir string
// Verbose is optional. If there is a non-null Logger and this
// field is true, then the Logger will also receive Debug messages,
// otherwise it will not receive such messages.
Verbose bool
}
// Session contains shared state for running experiments and/or other
// OONI related task (e.g. geolocation). Note that the Session isn't
// mean to be a long living object. The workflow is to create a Session,
// do the operations you need to do with it now, then make sure it is
// not referenced by other variables, so the Go GC can finalize it.
//
// Future directions
//
// We will eventually rewrite the code for running new experiments such
// that a Task will be created from a Session, such that experiments
// could share the same Session and save geolookups, etc. For now, we
// are in the suboptimal situations where Tasks create, use, and close
// their own session, thus running more lookups than needed.
type Session struct {
cl []context.CancelFunc
mtx sync.Mutex
submitter *probeservices.Submitter
sessp *engine.Session
// Hooks for testing (should not appear in Java/ObjC)
TestingCheckInBeforeNewProbeServicesClient func(ctx *Context)
TestingCheckInBeforeCheckIn func(ctx *Context)
}
// NewSession creates a new session. You should use a session for running
// a set of operations in a relatively short time frame. You SHOULD NOT create
// a single session and keep it all alive for the whole app lifecyle, since
// the Session code is not specifically designed for this use case.
func NewSession(config *SessionConfig) (*Session, error) {
kvstore, err := engine.NewFileSystemKVStore(config.StateDir)
if err != nil {
return nil, err
}
var availableps []model.Service
if config.ProbeServicesURL != "" {
availableps = append(availableps, model.Service{
Address: config.ProbeServicesURL,
Type: "https",
})
}
engineConfig := engine.SessionConfig{
AssetsDir: config.AssetsDir,
AvailableProbeServices: availableps,
KVStore: kvstore,
Logger: newLogger(config.Logger, config.Verbose),
SoftwareName: config.SoftwareName,
SoftwareVersion: config.SoftwareVersion,
TempDir: config.TempDir,
}
sessp, err := engine.NewSession(engineConfig)
if err != nil {
return nil, err
}
sess := &Session{sessp: sessp}
runtime.SetFinalizer(sess, sessionFinalizer)
ActiveSessions.Add(1)
return sess, nil
}
// sessionFinalizer finalizes a Session. While in general in Go code using a
// finalizer is probably unclean, it seems that using a finalizer when binding
// with Java/ObjC code is actually useful to simplify the apps.
func sessionFinalizer(sess *Session) {
for _, fn := range sess.cl {
fn()
}
sess.sessp.Close() // ignore return value
ActiveSessions.Add(-1)
}
// Context is the context of an operation. You use this context
// to cancel a long running operation by calling Cancel(). Because
// you create a Context from a Session and because the Session is
// keeping track of the Context instances it owns, you do don't
// need to call the Cancel method when you're done.
type Context struct {
cancel context.CancelFunc
ctx context.Context
}
// Cancel cancels pending operations using this context.
func (ctx *Context) Cancel() {
ctx.cancel()
}
// NewContext creates an new interruptible Context.
func (sess *Session) NewContext() *Context {
return sess.NewContextWithTimeout(-1)
}
// NewContextWithTimeout creates an new interruptible Context that will automatically
// cancel itself after the given timeout. Setting a zero or negative timeout implies
// there is no actual timeout configured for the Context.
func (sess *Session) NewContextWithTimeout(timeout int64) *Context {
sess.mtx.Lock()
defer sess.mtx.Unlock()
ctx, origcancel := newContext(timeout)
ActiveContexts.Add(1)
var once sync.Once
cancel := func() {
once.Do(func() {
ActiveContexts.Add(-1)
origcancel()
})
}
sess.cl = append(sess.cl, cancel)
return &Context{cancel: cancel, ctx: ctx}
}
// GeolocateResults contains the GeolocateTask results.
type GeolocateResults struct {
// ASN is the autonomous system number.
ASN string
// Country is the country code.
Country string
// IP is the IP address.
IP string
// Org is the commercial name of the ASN.
Org string
}
// MaybeUpdateResources ensures that resources are up to date.
func (sess *Session) MaybeUpdateResources(ctx *Context) error {
sess.mtx.Lock()
defer sess.mtx.Unlock()
return sess.sessp.MaybeUpdateResources(ctx.ctx)
}
// Geolocate performs a geolocate operation and returns the results. This method
// is (in Java terminology) synchronized with the session instance.
func (sess *Session) Geolocate(ctx *Context) (*GeolocateResults, error) {
sess.mtx.Lock()
defer sess.mtx.Unlock()
info, err := sess.sessp.LookupLocationContext(ctx.ctx)
if err != nil {
return nil, err
}
return &GeolocateResults{
ASN: fmt.Sprintf("AS%d", info.ASN),
Country: info.CountryCode,
IP: info.ProbeIP,
Org: info.NetworkName,
}, nil
}
// SubmitMeasurementResults contains the results of a single measurement submission
// to the OONI backends using the OONI collector API.
type SubmitMeasurementResults struct {
UpdatedMeasurement string
UpdatedReportID string
}
// Submit submits the given measurement and returns the results. This method is (in
// Java terminology) synchronized with the Session instance.
func (sess *Session) Submit(ctx *Context, measurement string) (*SubmitMeasurementResults, error) {
sess.mtx.Lock()
defer sess.mtx.Unlock()
if sess.submitter == nil {
psc, err := sess.sessp.NewProbeServicesClient(ctx.ctx)
if err != nil {
return nil, err
}
sess.submitter = probeservices.NewSubmitter(psc, sess.sessp.Logger())
}
var mm model.Measurement
if err := json.Unmarshal([]byte(measurement), &mm); err != nil {
return nil, err
}
if err := sess.submitter.Submit(ctx.ctx, &mm); err != nil {
return nil, err
}
data, err := json.Marshal(mm)
runtimex.PanicOnError(err, "json.Marshal should not fail here")
return &SubmitMeasurementResults{
UpdatedMeasurement: string(data),
UpdatedReportID: mm.ReportID,
}, nil
}
// CheckInConfigWebConnectivity is the configuration for the WebConnectivity test
type CheckInConfigWebConnectivity struct {
CategoryCodes []string // CategoryCodes is an array of category codes
}
// Add a category code to the array in CheckInConfigWebConnectivity
func (ckw *CheckInConfigWebConnectivity) Add(cat string) {
ckw.CategoryCodes = append(ckw.CategoryCodes, cat)
}
func (ckw *CheckInConfigWebConnectivity) toModel() model.CheckInConfigWebConnectivity {
return model.CheckInConfigWebConnectivity{
CategoryCodes: ckw.CategoryCodes,
}
}
// CheckInConfig contains configuration for calling the checkin API.
type CheckInConfig struct {
Charging bool // Charging indicate if the phone is actually charging
OnWiFi bool // OnWiFi indicate if the phone is actually connected to a WiFi network
Platform string // Platform of the probe
RunType string // RunType
SoftwareName string // SoftwareName of the probe
SoftwareVersion string // SoftwareVersion of the probe
WebConnectivity *CheckInConfigWebConnectivity // WebConnectivity class contain an array of categories
}
// CheckInInfoWebConnectivity contains the array of URLs returned by the checkin API
type CheckInInfoWebConnectivity struct {
ReportID string
URLs []model.URLInfo
}
// URLInfo contains info on a test lists URL
type URLInfo struct {
CategoryCode string
CountryCode string
URL string
}
// Size returns the number of URLs.
func (ckw *CheckInInfoWebConnectivity) Size() int64 {
return int64(len(ckw.URLs))
}
// At gets the URLInfo at position idx from CheckInInfoWebConnectivity.URLs
func (ckw *CheckInInfoWebConnectivity) At(idx int64) *URLInfo {
if idx < 0 || int(idx) >= len(ckw.URLs) {
return nil
}
w := ckw.URLs[idx]
return &URLInfo{
CategoryCode: w.CategoryCode,
CountryCode: w.CountryCode,
URL: w.URL,
}
}
func newCheckInInfoWebConnectivity(ckw *model.CheckInInfoWebConnectivity) *CheckInInfoWebConnectivity {
if ckw == nil {
return nil
}
out := new(CheckInInfoWebConnectivity)
out.ReportID = ckw.ReportID
out.URLs = ckw.URLs
return out
}
// CheckInInfo contains the return test objects from the checkin API
type CheckInInfo struct {
WebConnectivity *CheckInInfoWebConnectivity
}
// CheckIn function is called by probes asking if there are tests to be run
// The config argument contains the mandatory settings.
// Returns the list of tests to run and the URLs, on success, or an explanatory error, in case of failure.
func (sess *Session) CheckIn(ctx *Context, config *CheckInConfig) (*CheckInInfo, error) {
sess.mtx.Lock()
defer sess.mtx.Unlock()
if config.WebConnectivity == nil {
return nil, errors.New("oonimkall: missing webconnectivity config")
}
info, err := sess.sessp.LookupLocationContext(ctx.ctx)
if err != nil {
return nil, err
}
if sess.TestingCheckInBeforeNewProbeServicesClient != nil {
sess.TestingCheckInBeforeNewProbeServicesClient(ctx)
}
psc, err := sess.sessp.NewProbeServicesClient(ctx.ctx)
if err != nil {
return nil, err
}
if sess.TestingCheckInBeforeCheckIn != nil {
sess.TestingCheckInBeforeCheckIn(ctx)
}
cfg := model.CheckInConfig{
Charging: config.Charging,
OnWiFi: config.OnWiFi,
Platform: config.Platform,
ProbeASN: info.ASNString(),
ProbeCC: info.CountryCode,
RunType: config.RunType,
SoftwareVersion: config.SoftwareVersion,
WebConnectivity: config.WebConnectivity.toModel(),
}
result, err := psc.CheckIn(ctx.ctx, cfg)
if err != nil {
return nil, err
}
return &CheckInInfo{
WebConnectivity: newCheckInInfoWebConnectivity(result.WebConnectivity),
}, nil
}
+440
View File
@@ -0,0 +1,440 @@
package oonimkall_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"os"
"runtime"
"strings"
"testing"
"time"
engine "github.com/ooni/probe-cli/v3/internal/engine"
"github.com/ooni/probe-cli/v3/internal/engine/geolocate"
"github.com/ooni/probe-cli/v3/internal/engine/model"
"github.com/ooni/probe-cli/v3/pkg/oonimkall"
)
func NewSessionWithAssetsDir(assetsDir string) (*oonimkall.Session, error) {
return oonimkall.NewSession(&oonimkall.SessionConfig{
AssetsDir: assetsDir,
ProbeServicesURL: "https://ams-pg-test.ooni.org/",
SoftwareName: "oonimkall-test",
SoftwareVersion: "0.1.0",
StateDir: "../testdata/oonimkall/state",
TempDir: "../testdata/",
})
}
func NewSession() (*oonimkall.Session, error) {
return NewSessionWithAssetsDir("../testdata/oonimkall/assets")
}
func TestNewSessionWithInvalidStateDir(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
sess, err := oonimkall.NewSession(&oonimkall.SessionConfig{
StateDir: "",
})
if err == nil || !strings.HasSuffix(err.Error(), "no such file or directory") {
t.Fatal("not the error we expected")
}
if sess != nil {
t.Fatal("expected a nil Session here")
}
}
func TestNewSessionWithMissingSoftwareName(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
sess, err := oonimkall.NewSession(&oonimkall.SessionConfig{
StateDir: "../testdata/oonimkall/state",
})
if err == nil || err.Error() != "AssetsDir is empty" {
t.Fatal("not the error we expected")
}
if sess != nil {
t.Fatal("expected a nil Session here")
}
}
func TestMaybeUpdateResourcesWithCancelledContext(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
dir, err := ioutil.TempDir("", "xx")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
sess, err := NewSessionWithAssetsDir(dir)
if err != nil {
t.Fatal(err)
}
ctx := sess.NewContext()
ctx.Cancel() // cause immediate failure
err = sess.MaybeUpdateResources(ctx)
if !errors.Is(err, context.Canceled) {
t.Fatalf("not the error we expected: %+v", err)
}
}
func ReduceErrorForGeolocate(err error) error {
if err == nil {
return errors.New("we expected an error here")
}
if errors.Is(err, context.Canceled) {
return nil // when we have not downloaded the resources yet
}
if !errors.Is(err, geolocate.ErrAllIPLookuppersFailed) {
return nil // otherwise
}
return fmt.Errorf("not the error we expected: %w", err)
}
func TestGeolocateWithCancelledContext(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
sess, err := NewSession()
if err != nil {
t.Fatal(err)
}
ctx := sess.NewContext()
ctx.Cancel() // cause immediate failure
location, err := sess.Geolocate(ctx)
if err := ReduceErrorForGeolocate(err); err != nil {
t.Fatal(err)
}
if location != nil {
t.Fatal("expected nil location here")
}
}
func TestGeolocateGood(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
sess, err := NewSession()
if err != nil {
t.Fatal(err)
}
ctx := sess.NewContext()
location, err := sess.Geolocate(ctx)
if err != nil {
t.Fatal(err)
}
if location.ASN == "" {
t.Fatal("location.ASN is empty")
}
if location.Country == "" {
t.Fatal("location.Country is empty")
}
if location.IP == "" {
t.Fatal("location.IP is empty")
}
if location.Org == "" {
t.Fatal("location.Org is empty")
}
}
func ReduceErrorForSubmitter(err error) error {
if err == nil {
return errors.New("we expected an error here")
}
if errors.Is(err, context.Canceled) {
return nil // when we have not downloaded the resources yet
}
if err.Error() == "all available probe services failed" {
return nil // otherwise
}
return fmt.Errorf("not the error we expected: %w", err)
}
func TestSubmitWithCancelledContext(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
sess, err := NewSession()
if err != nil {
t.Fatal(err)
}
ctx := sess.NewContext()
ctx.Cancel() // cause immediate failure
result, err := sess.Submit(ctx, "{}")
if err := ReduceErrorForSubmitter(err); err != nil {
t.Fatalf("not the error we expected: %+v", err)
}
if result != nil {
t.Fatal("expected nil result here")
}
}
func TestSubmitWithInvalidJSON(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
sess, err := NewSession()
if err != nil {
t.Fatal(err)
}
ctx := sess.NewContext()
result, err := sess.Submit(ctx, "{")
if err == nil || err.Error() != "unexpected end of JSON input" {
t.Fatalf("not the error we expected: %+v", err)
}
if result != nil {
t.Fatal("expected nil result here")
}
}
func DoSubmission(ctx *oonimkall.Context, sess *oonimkall.Session) error {
inputm := model.Measurement{
DataFormatVersion: "0.2.0",
MeasurementStartTime: "2019-10-28 12:51:07",
MeasurementRuntime: 1.71,
ProbeASN: "AS30722",
ProbeCC: "IT",
ProbeIP: "127.0.0.1",
ReportID: "",
ResolverIP: "172.217.33.129",
SoftwareName: "miniooni",
SoftwareVersion: "0.1.0-dev",
TestKeys: map[string]bool{"success": true},
TestName: "example",
TestVersion: "0.1.0",
}
inputd, err := json.Marshal(inputm)
if err != nil {
return err
}
result, err := sess.Submit(ctx, string(inputd))
if err != nil {
return fmt.Errorf("session_test.go: submit failed: %w", err)
}
if result.UpdatedMeasurement == "" {
return errors.New("expected non empty measurement")
}
if result.UpdatedReportID == "" {
return errors.New("expected non empty report ID")
}
var outputm model.Measurement
return json.Unmarshal([]byte(result.UpdatedMeasurement), &outputm)
}
func TestSubmitMeasurementGood(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
sess, err := NewSession()
if err != nil {
t.Fatal(err)
}
ctx := sess.NewContext()
if err := DoSubmission(ctx, sess); err != nil {
t.Fatal(err)
}
}
func TestSubmitCancelContextAfterFirstSubmission(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
sess, err := NewSession()
if err != nil {
t.Fatal(err)
}
ctx := sess.NewContext()
if err := DoSubmission(ctx, sess); err != nil {
t.Fatal(err)
}
ctx.Cancel() // fail second submission
err = DoSubmission(ctx, sess)
if err == nil || !strings.HasPrefix(err.Error(), "session_test.go: submit failed") {
t.Fatalf("not the error we expected: %+v", err)
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("not the error we expected: %+v", err)
}
}
func TestCheckInSuccess(t *testing.T) {
sess, err := NewSession()
if err != nil {
t.Fatal(err)
}
ctx := sess.NewContext()
config := oonimkall.CheckInConfig{
Charging: true,
OnWiFi: true,
Platform: "android",
RunType: "timed",
SoftwareName: "ooniprobe-android",
SoftwareVersion: "2.7.1",
WebConnectivity: &oonimkall.CheckInConfigWebConnectivity{},
}
config.WebConnectivity.Add("NEWS")
config.WebConnectivity.Add("CULTR")
result, err := sess.CheckIn(ctx, &config)
if err != nil {
t.Fatalf("unexpected error: %+v", err)
}
if result == nil || result.WebConnectivity == nil {
t.Fatal("got nil result or WebConnectivity")
}
if len(result.WebConnectivity.URLs) < 1 {
t.Fatal("unexpected number of URLs")
}
if result.WebConnectivity.ReportID == "" {
t.Fatal("got empty report ID")
}
siz := result.WebConnectivity.Size()
if siz <= 0 {
t.Fatal("unexpected number of URLs")
}
for idx := int64(0); idx < siz; idx++ {
entry := result.WebConnectivity.At(idx)
if entry.CategoryCode != "NEWS" && entry.CategoryCode != "CULTR" {
t.Fatalf("unexpected category code: %+v", entry)
}
}
if result.WebConnectivity.At(-1) != nil {
t.Fatal("expected nil here")
}
if result.WebConnectivity.At(siz) != nil {
t.Fatal("expected nil here")
}
}
func TestCheckInLookupLocationFailure(t *testing.T) {
sess, err := NewSession()
if err != nil {
t.Fatal(err)
}
ctx := sess.NewContext()
config := oonimkall.CheckInConfig{
Charging: true,
OnWiFi: true,
Platform: "android",
RunType: "timed",
SoftwareName: "ooniprobe-android",
SoftwareVersion: "2.7.1",
WebConnectivity: &oonimkall.CheckInConfigWebConnectivity{},
}
config.WebConnectivity.Add("NEWS")
config.WebConnectivity.Add("CULTR")
ctx.Cancel() // immediate failure
result, err := sess.CheckIn(ctx, &config)
if !errors.Is(err, geolocate.ErrAllIPLookuppersFailed) {
t.Fatalf("not the error we expected: %+v", err)
}
if result != nil {
t.Fatal("expected nil result here")
}
}
func TestCheckInNewProbeServicesFailure(t *testing.T) {
sess, err := NewSession()
if err != nil {
t.Fatal(err)
}
sess.TestingCheckInBeforeNewProbeServicesClient = func(ctx *oonimkall.Context) {
ctx.Cancel() // cancel execution
}
ctx := sess.NewContext()
config := oonimkall.CheckInConfig{
Charging: true,
OnWiFi: true,
Platform: "android",
RunType: "timed",
SoftwareName: "ooniprobe-android",
SoftwareVersion: "2.7.1",
WebConnectivity: &oonimkall.CheckInConfigWebConnectivity{},
}
config.WebConnectivity.Add("NEWS")
config.WebConnectivity.Add("CULTR")
result, err := sess.CheckIn(ctx, &config)
if !errors.Is(err, engine.ErrAllProbeServicesFailed) {
t.Fatalf("not the error we expected: %+v", err)
}
if result != nil {
t.Fatal("expected nil result here")
}
}
func TestCheckInCheckInFailure(t *testing.T) {
sess, err := NewSession()
if err != nil {
t.Fatal(err)
}
sess.TestingCheckInBeforeCheckIn = func(ctx *oonimkall.Context) {
ctx.Cancel() // cancel execution
}
ctx := sess.NewContext()
config := oonimkall.CheckInConfig{
Charging: true,
OnWiFi: true,
Platform: "android",
RunType: "timed",
SoftwareName: "ooniprobe-android",
SoftwareVersion: "2.7.1",
WebConnectivity: &oonimkall.CheckInConfigWebConnectivity{},
}
config.WebConnectivity.Add("NEWS")
config.WebConnectivity.Add("CULTR")
result, err := sess.CheckIn(ctx, &config)
if !errors.Is(err, context.Canceled) {
t.Fatalf("not the error we expected: %+v", err)
}
if result != nil {
t.Fatal("expected nil result here")
}
}
func TestCheckInNoParams(t *testing.T) {
sess, err := NewSession()
if err != nil {
t.Fatal(err)
}
ctx := sess.NewContext()
config := oonimkall.CheckInConfig{
Charging: true,
OnWiFi: true,
Platform: "android",
RunType: "timed",
SoftwareName: "ooniprobe-android",
SoftwareVersion: "2.7.1",
}
result, err := sess.CheckIn(ctx, &config)
if err == nil || err.Error() != "oonimkall: missing webconnectivity config" {
t.Fatalf("not the error we expected: %+v", err)
}
if result != nil {
t.Fatal("unexpected not nil result here")
}
}
func TestMain(m *testing.M) {
// Here we're basically testing whether eventually the finalizers
// will run and the number of active sessions and cancels will become
// balanced. Especially for the number of active cancels, this is an
// indication that we've correctly cleaned them up in the session.
if exitcode := m.Run(); exitcode != 0 {
os.Exit(exitcode)
}
for {
runtime.GC()
m, n := oonimkall.ActiveContexts.Load(), oonimkall.ActiveSessions.Load()
fmt.Printf("./oonimkall: ActiveContexts: %d; ActiveSessions: %d\n", m, n)
if m == 0 && n == 0 {
break
}
time.Sleep(1 * time.Second)
}
os.Exit(0)
}
+10
View File
@@ -0,0 +1,10 @@
package oonimkall
import "testing"
func TestNewCheckInInfoWebConnectivityNilPointer(t *testing.T) {
out := newCheckInInfoWebConnectivity(nil)
if out != nil {
t.Fatal("expected nil pointer")
}
}
+29
View File
@@ -0,0 +1,29 @@
package oonimkall
import (
"context"
"math"
"time"
)
const maxTimeout = int64(time.Duration(math.MaxInt64) / time.Second)
func clampTimeout(timeout, max int64) int64 {
if timeout > max {
timeout = max
}
return timeout
}
func newContext(timeout int64) (context.Context, context.CancelFunc) {
return newContextEx(timeout, maxTimeout)
}
func newContextEx(timeout, max int64) (context.Context, context.CancelFunc) {
if timeout > 0 {
timeout = clampTimeout(timeout, max)
return context.WithTimeout(
context.Background(), time.Duration(timeout)*time.Second)
}
return context.WithCancel(context.Background())
}
+102
View File
@@ -0,0 +1,102 @@
package oonimkall
import (
"testing"
"time"
"github.com/ooni/probe-cli/v3/internal/engine/atomicx"
)
func TestClampTimeout(t *testing.T) {
if clampTimeout(-1, maxTimeout) != -1 {
t.Fatal("unexpected result here")
}
if clampTimeout(0, maxTimeout) != 0 {
t.Fatal("unexpected result here")
}
if clampTimeout(60, maxTimeout) != 60 {
t.Fatal("unexpected result here")
}
if clampTimeout(maxTimeout, maxTimeout) != maxTimeout {
t.Fatal("unexpected result here")
}
if clampTimeout(maxTimeout+1, maxTimeout) != maxTimeout {
t.Fatal("unexpected result here")
}
}
func TestNewContextWithZeroTimeout(t *testing.T) {
here := atomicx.NewInt64()
ctx, cancel := newContext(0)
defer cancel()
go func() {
<-time.After(250 * time.Millisecond)
here.Add(1)
cancel()
}()
<-ctx.Done()
if here.Load() != 1 {
t.Fatal("context timeout not working as intended")
}
}
func TestNewContextWithNegativeTimeout(t *testing.T) {
here := atomicx.NewInt64()
ctx, cancel := newContext(-1)
defer cancel()
go func() {
<-time.After(250 * time.Millisecond)
here.Add(1)
cancel()
}()
<-ctx.Done()
if here.Load() != 1 {
t.Fatal("context timeout not working as intended")
}
}
func TestNewContextWithHugeTimeout(t *testing.T) {
here := atomicx.NewInt64()
ctx, cancel := newContext(maxTimeout + 1)
defer cancel()
go func() {
<-time.After(250 * time.Millisecond)
here.Add(1)
cancel()
}()
<-ctx.Done()
if here.Load() != 1 {
t.Fatal("context timeout not working as intended")
}
}
func TestNewContextWithReasonableTimeout(t *testing.T) {
here := atomicx.NewInt64()
ctx, cancel := newContext(1)
defer cancel()
go func() {
<-time.After(5 * time.Second)
here.Add(1)
cancel()
}()
<-ctx.Done()
if here.Load() != 0 {
t.Fatal("context timeout not working as intended")
}
}
func TestNewContextWithArtificiallyLowMaxTimeout(t *testing.T) {
here := atomicx.NewInt64()
const maxTimeout = 2
ctx, cancel := newContextEx(maxTimeout+1, maxTimeout)
defer cancel()
go func() {
<-time.After(30 * time.Second)
here.Add(1)
cancel()
}()
<-ctx.Done()
if here.Load() != 0 {
t.Fatal("context timeout not working as intended")
}
}
+69
View File
@@ -0,0 +1,69 @@
package oonimkall
import (
"fmt"
"github.com/ooni/probe-cli/v3/internal/engine/model"
)
type loggerVerbose struct {
Logger
}
func (slv loggerVerbose) Debugf(format string, v ...interface{}) {
slv.Debug(fmt.Sprintf(format, v...))
}
func (slv loggerVerbose) Infof(format string, v ...interface{}) {
slv.Info(fmt.Sprintf(format, v...))
}
func (slv loggerVerbose) Warnf(format string, v ...interface{}) {
slv.Warn(fmt.Sprintf(format, v...))
}
type loggerNormal struct {
Logger
}
func (sln loggerNormal) Debugf(format string, v ...interface{}) {
// nothing
}
func (sln loggerNormal) Debug(msg string) {
// nothing
}
func (sln loggerNormal) Infof(format string, v ...interface{}) {
sln.Info(fmt.Sprintf(format, v...))
}
func (sln loggerNormal) Warnf(format string, v ...interface{}) {
sln.Warn(fmt.Sprintf(format, v...))
}
type loggerQuiet struct{}
func (loggerQuiet) Debugf(format string, v ...interface{}) {
// nothing
}
func (loggerQuiet) Debug(msg string) {
// nothing
}
func (loggerQuiet) Infof(format string, v ...interface{}) {
// nothing
}
func (loggerQuiet) Info(msg string) {
// nothing
}
func (loggerQuiet) Warnf(format string, v ...interface{}) {
// nothing
}
func (loggerQuiet) Warn(msg string) {
// nothing
}
func newLogger(logger Logger, verbose bool) model.Logger {
if logger == nil {
return loggerQuiet{}
}
if verbose {
return loggerVerbose{Logger: logger}
}
return loggerNormal{Logger: logger}
}
+118
View File
@@ -0,0 +1,118 @@
package oonimkall
import (
"errors"
"fmt"
"io"
"sync"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ooni/probe-cli/v3/internal/engine/model"
)
type RecordingLogger struct {
DebugLogs []string
InfoLogs []string
WarnLogs []string
mu sync.Mutex
}
func (rl *RecordingLogger) Debug(msg string) {
rl.mu.Lock()
defer rl.mu.Unlock()
rl.DebugLogs = append(rl.DebugLogs, msg)
}
func (rl *RecordingLogger) Info(msg string) {
rl.mu.Lock()
defer rl.mu.Unlock()
rl.InfoLogs = append(rl.InfoLogs, msg)
}
func (rl *RecordingLogger) Warn(msg string) {
rl.mu.Lock()
defer rl.mu.Unlock()
rl.WarnLogs = append(rl.WarnLogs, msg)
}
func LoggerEmitMessages(logger model.Logger) {
logger.Warnf("a formatted warn message: %+v", io.EOF)
logger.Warn("a warn string")
logger.Infof("a formatted info message: %+v", io.EOF)
logger.Info("a info string")
logger.Debugf("a formatted debug message: %+v", io.EOF)
logger.Debug("a debug string")
}
func TestNewLoggerNilLogger(t *testing.T) {
// The objective of this test is to make sure that, even if the
// Logger instance is nil, we get back something that works, that
// is, something that does not crash when it is used.
logger := newLogger(nil, true)
LoggerEmitMessages(logger)
}
func (rl *RecordingLogger) VerifyNumberOfEntries(debugEntries int) error {
if len(rl.DebugLogs) != debugEntries {
return errors.New("unexpected number of debug messages")
}
if len(rl.InfoLogs) != 2 {
return errors.New("unexpected number of info messages")
}
if len(rl.WarnLogs) != 2 {
return errors.New("unexpected number of warn messages")
}
return nil
}
func (rl *RecordingLogger) ExpectedEntries(level string) []string {
return []string{
fmt.Sprintf("a formatted %s message: %+v", level, io.EOF),
fmt.Sprintf("a %s string", level),
}
}
func (rl *RecordingLogger) CheckNonVerboseEntries() error {
if diff := cmp.Diff(rl.InfoLogs, rl.ExpectedEntries("info")); diff != "" {
return errors.New(diff)
}
if diff := cmp.Diff(rl.WarnLogs, rl.ExpectedEntries("warn")); diff != "" {
return errors.New(diff)
}
return nil
}
func (rl *RecordingLogger) CheckVerboseEntries() error {
if diff := cmp.Diff(rl.DebugLogs, rl.ExpectedEntries("debug")); diff != "" {
return errors.New(diff)
}
return nil
}
func TestNewLoggerQuietLogger(t *testing.T) {
handler := new(RecordingLogger)
logger := newLogger(handler, false)
LoggerEmitMessages(logger)
if err := handler.VerifyNumberOfEntries(0); err != nil {
t.Fatal(err)
}
if err := handler.CheckNonVerboseEntries(); err != nil {
t.Fatal(err)
}
}
func TestNewLoggerVerboseLogger(t *testing.T) {
handler := new(RecordingLogger)
logger := newLogger(handler, true)
LoggerEmitMessages(logger)
if err := handler.VerifyNumberOfEntries(2); err != nil {
t.Fatal(err)
}
if err := handler.CheckNonVerboseEntries(); err != nil {
t.Fatal(err)
}
if err := handler.CheckVerboseEntries(); err != nil {
t.Fatal(err)
}
}
+114
View File
@@ -0,0 +1,114 @@
// Package oonimkall implements APIs used by OONI mobile apps. We
// expose these APIs to mobile apps using gomobile.
//
// We expose two APIs: the task API, which is derived from the
// API originally exposed by Measurement Kit, and the session API,
// which is a Go API that mobile apps can use via `gomobile`.
//
// This package is named oonimkall because it contains a partial
// reimplementation of the mkall API implemented by Measurement Kit
// in, e.g., https://github.com/measurement-kit/mkall-ios.
//
// Task API
//
// The basic tenet of the task API is that you define an experiment
// task you wanna run using a JSON, then you start a task for it, and
// you receive events as serialized JSONs. In addition to this
// functionality, we also include extra APIs used by OONI mobile.
//
// The task API was first defined in Measurement Kit v0.9.0. In this
// context, it was called "the FFI API". The API we expose here is not
// strictly an FFI API, but is close enough for the purpose of using
// OONI from Android and iOS. See https://git.io/Jv4Rv
// (measurement-kit/measurement-kit@v0.10.9) for a comprehensive
// description of MK's FFI API.
//
// See also https://github.com/ooni/probe-cli/v3/internal/engine/pull/347 for the
// design document describing the task API.
//
// See also https://github.com/ooni/probe-cli/v3/internal/engine/blob/master/DESIGN.md,
// which explains why we implemented the oonimkall API.
//
// Session API
//
// The Session API is a Go API that can be exported to mobile apps
// using the gomobile tool. The latest design document for this API is
// at https://github.com/ooni/probe-cli/v3/internal/engine/pull/954.
//
// The basic tenet of the session API is that you create an instance
// of `Session` and use it to perform the operations you need.
package oonimkall
import (
"context"
"encoding/json"
"github.com/ooni/probe-cli/v3/internal/engine/atomicx"
"github.com/ooni/probe-cli/v3/internal/engine/runtimex"
"github.com/ooni/probe-cli/v3/pkg/oonimkall/tasks"
)
// Task is an asynchronous task running an experiment. It mimics the
// namesake concept initially implemented in Measurement Kit.
//
// Future directions
//
// Currently Task and Session are two unrelated APIs. As part of
// evolving the APIs with which apps interact with the engine, we
// will modify Task to run in the context of a Session. We will
// do that to save extra lookups and to allow several experiments
// running as subsequent Tasks to reuse the Session connections
// created with the OONI probe services backends.
type Task struct {
cancel context.CancelFunc
isdone *atomicx.Int64
isstopped *atomicx.Int64
out chan *tasks.Event
}
// StartTask starts an asynchronous task. The input argument is a
// serialized JSON conforming to MK v0.10.9's API.
func StartTask(input string) (*Task, error) {
var settings tasks.Settings
if err := json.Unmarshal([]byte(input), &settings); err != nil {
return nil, err
}
const bufsiz = 128 // common case: we don't want runner to block
ctx, cancel := context.WithCancel(context.Background())
task := &Task{
cancel: cancel,
isdone: atomicx.NewInt64(),
isstopped: atomicx.NewInt64(),
out: make(chan *tasks.Event, bufsiz),
}
go func() {
defer close(task.out)
defer task.isstopped.Add(1)
tasks.Run(ctx, &settings, task.out)
}()
return task, nil
}
// WaitForNextEvent blocks until the next event occurs. The returned
// string is a serialized JSON following MK v0.10.9's API.
func (t *Task) WaitForNextEvent() string {
const terminated = `{"key":"task_terminated","value":{}}` // like MK
evp := <-t.out
if evp == nil {
t.isdone.Add(1)
return terminated
}
data, err := json.Marshal(evp)
runtimex.PanicOnError(err, "json.Marshal failed")
return string(data)
}
// IsDone returns true if the task is done.
func (t *Task) IsDone() bool {
return t.isdone.Load() != 0
}
// Interrupt interrupts the task.
func (t *Task) Interrupt() {
t.cancel()
}
+560
View File
@@ -0,0 +1,560 @@
package oonimkall_test
import (
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/ooni/probe-cli/v3/internal/engine/model"
"github.com/ooni/probe-cli/v3/pkg/oonimkall"
"github.com/ooni/probe-cli/v3/pkg/oonimkall/tasks"
)
type eventlike struct {
Key string `json:"key"`
Value map[string]interface{} `json:"value"`
}
func TestGood(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
task, err := oonimkall.StartTask(`{
"assets_dir": "../testdata/oonimkall/assets",
"log_level": "DEBUG",
"name": "Example",
"options": {
"software_name": "oonimkall-test",
"software_version": "0.1.0"
},
"state_dir": "../testdata/oonimkall/state",
"version": 1
}`)
if err != nil {
t.Fatal(err)
}
// interrupt the task so we also exercise this functionality
go func() {
<-time.After(time.Second)
task.Interrupt()
}()
for !task.IsDone() {
eventstr := task.WaitForNextEvent()
var event eventlike
if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
t.Fatal(err)
}
if event.Key == "failure.startup" {
t.Fatal("unexpected failure.startup event")
}
}
// make sure we only see task_terminated at this point
for {
eventstr := task.WaitForNextEvent()
var event eventlike
if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
t.Fatal(err)
}
if event.Key != "task_terminated" {
t.Fatalf("unexpected event.Key: %s", event.Key)
}
break
}
}
func TestWithMeasurementFailure(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
task, err := oonimkall.StartTask(`{
"assets_dir": "../testdata/oonimkall/assets",
"log_level": "DEBUG",
"name": "ExampleWithFailure",
"options": {
"no_geoip": true,
"no_resolver_lookup": true,
"software_name": "oonimkall-test",
"software_version": "0.1.0"
},
"state_dir": "../testdata/oonimkall/state",
"version": 1
}`)
if err != nil {
t.Fatal(err)
}
for !task.IsDone() {
eventstr := task.WaitForNextEvent()
var event eventlike
if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
t.Fatal(err)
}
if event.Key == "failure.startup" {
t.Fatal("unexpected failure.startup event")
}
}
}
func TestInvalidJSON(t *testing.T) {
task, err := oonimkall.StartTask(`{`)
var syntaxerr *json.SyntaxError
if !errors.As(err, &syntaxerr) {
t.Fatal("not the expected error")
}
if task != nil {
t.Fatal("task is not nil")
}
}
func TestUnsupportedSetting(t *testing.T) {
task, err := oonimkall.StartTask(`{
"assets_dir": "../testdata/oonimkall/assets",
"log_level": "DEBUG",
"name": "Example",
"options": {
"software_name": "oonimkall-test",
"software_version": "0.1.0"
},
"state_dir": "../testdata/oonimkall/state"
}`)
if err != nil {
t.Fatal(err)
}
var seen bool
for !task.IsDone() {
eventstr := task.WaitForNextEvent()
var event eventlike
if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
t.Fatal(err)
}
if event.Key == "failure.startup" {
if strings.Contains(eventstr, tasks.FailureInvalidVersion) {
seen = true
}
}
}
if !seen {
t.Fatal("did not see failure.startup with invalid version info")
}
}
func TestEmptyStateDir(t *testing.T) {
task, err := oonimkall.StartTask(`{
"assets_dir": "../testdata/oonimkall/assets",
"log_level": "DEBUG",
"name": "Example",
"options": {
"software_name": "oonimkall-test",
"software_version": "0.1.0"
},
"version": 1
}`)
if err != nil {
t.Fatal(err)
}
var seen bool
for !task.IsDone() {
eventstr := task.WaitForNextEvent()
var event eventlike
if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
t.Fatal(err)
}
if event.Key == "failure.startup" {
if strings.Contains(eventstr, "mkdir : no such file or directory") {
seen = true
}
}
}
if !seen {
t.Fatal("did not see failure.startup with info that state dir is empty")
}
}
func TestEmptyAssetsDir(t *testing.T) {
task, err := oonimkall.StartTask(`{
"log_level": "DEBUG",
"name": "Example",
"options": {
"software_name": "oonimkall-test",
"software_version": "0.1.0"
},
"state_dir": "../testdata/oonimkall/state",
"version": 1
}`)
if err != nil {
t.Fatal(err)
}
var seen bool
for !task.IsDone() {
eventstr := task.WaitForNextEvent()
var event eventlike
if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
t.Fatal(err)
}
if event.Key == "failure.startup" {
if strings.Contains(eventstr, "AssetsDir is empty") {
seen = true
}
}
}
if !seen {
t.Fatal("did not see failure.startup")
}
}
func TestUnknownExperiment(t *testing.T) {
task, err := oonimkall.StartTask(`{
"assets_dir": "../testdata/oonimkall/assets",
"log_level": "DEBUG",
"name": "Antani",
"options": {
"software_name": "oonimkall-test",
"software_version": "0.1.0"
},
"state_dir": "../testdata/oonimkall/state",
"version": 1
}`)
if err != nil {
t.Fatal(err)
}
var seen bool
for !task.IsDone() {
eventstr := task.WaitForNextEvent()
var event eventlike
if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
t.Fatal(err)
}
if event.Key == "failure.startup" {
if strings.Contains(eventstr, "no such experiment: ") {
seen = true
}
}
}
if !seen {
t.Fatal("did not see failure.startup")
}
}
func TestInputIsRequired(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
task, err := oonimkall.StartTask(`{
"assets_dir": "../testdata/oonimkall/assets",
"log_level": "DEBUG",
"name": "ExampleWithInput",
"options": {
"software_name": "oonimkall-test",
"software_version": "0.1.0"
},
"state_dir": "../testdata/oonimkall/state",
"version": 1
}`)
if err != nil {
t.Fatal(err)
}
var seen bool
for !task.IsDone() {
eventstr := task.WaitForNextEvent()
var event eventlike
if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
t.Fatal(err)
}
if event.Key == "failure.startup" {
if strings.Contains(eventstr, "no input provided") {
seen = true
}
}
}
if !seen {
t.Fatal("did not see failure.startup")
}
}
func TestMaxRuntime(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
begin := time.Now()
task, err := oonimkall.StartTask(`{
"assets_dir": "../testdata/oonimkall/assets",
"inputs": ["a", "b", "c"],
"name": "ExampleWithInput",
"options": {
"max_runtime": 1,
"software_name": "oonimkall-test",
"software_version": "0.1.0"
},
"state_dir": "../testdata/oonimkall/state",
"version": 1
}`)
if err != nil {
t.Fatal(err)
}
for !task.IsDone() {
eventstr := task.WaitForNextEvent()
var event eventlike
if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
t.Fatal(err)
}
if event.Key == "failure.startup" {
t.Fatal(eventstr)
}
}
// The runtime is long because of ancillary operations and is even more
// longer because of self shaping we may be performing (especially in
// CI builds) using `-tags shaping`). We have experimentally determined
// that ~10 seconds is the typical CI test run time. See:
//
// 1. https://github.com/ooni/probe-cli/v3/internal/engine/pull/588/checks?check_run_id=667263788
//
// 2. https://github.com/ooni/probe-cli/v3/internal/engine/pull/588/checks?check_run_id=667263855
//
// In case there are further timeouts, e.g. in the sessionresolver, the
// time used by the experiment will be much more. This is for example the
// case in https://github.com/ooni/probe-cli/v3/internal/engine/issues/1005.
if time.Now().Sub(begin) > 10*time.Second {
t.Fatal("expected shorter runtime")
}
}
func TestInterruptExampleWithInput(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
t.Skip("Skipping broken test; see https://github.com/ooni/probe-cli/v3/internal/engine/issues/992")
task, err := oonimkall.StartTask(`{
"assets_dir": "../testdata/oonimkall/assets",
"inputs": [
"http://www.kernel.org/",
"http://www.x.org/",
"http://www.microsoft.com/",
"http://www.slashdot.org/",
"http://www.repubblica.it/",
"http://www.google.it/",
"http://ooni.org/"
],
"name": "ExampleWithInputNonInterruptible",
"options": {
"software_name": "oonimkall-test",
"software_version": "0.1.0"
},
"state_dir": "../testdata/oonimkall/state",
"version": 1
}`)
if err != nil {
t.Fatal(err)
}
var keys []string
for !task.IsDone() {
eventstr := task.WaitForNextEvent()
var event eventlike
if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
t.Fatal(err)
}
switch event.Key {
case "failure.startup":
t.Fatal(eventstr)
case "status.measurement_start":
go task.Interrupt()
}
// We compress the keys. What matters is basically that we
// see just one of the many possible measurements here.
if keys == nil || keys[len(keys)-1] != event.Key {
keys = append(keys, event.Key)
}
}
expect := []string{
"status.queued",
"status.started",
"status.progress",
"status.geoip_lookup",
"status.resolver_lookup",
"status.progress",
"status.report_create",
"status.measurement_start",
"log",
"status.progress",
"measurement",
"status.measurement_submission",
"status.measurement_done",
"status.end",
"task_terminated",
}
if diff := cmp.Diff(expect, keys); diff != "" {
t.Fatal(diff)
}
}
func TestInterruptNdt7(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
task, err := oonimkall.StartTask(`{
"assets_dir": "../testdata/oonimkall/assets",
"name": "Ndt7",
"options": {
"software_name": "oonimkall-test",
"software_version": "0.1.0"
},
"state_dir": "../testdata/oonimkall/state",
"version": 1
}`)
if err != nil {
t.Fatal(err)
}
go func() {
<-time.After(11 * time.Second)
task.Interrupt()
}()
var keys []string
for !task.IsDone() {
eventstr := task.WaitForNextEvent()
var event eventlike
if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
t.Fatal(err)
}
if event.Key == "failure.startup" {
t.Fatal(eventstr)
}
// We compress the keys because we don't know how many
// status.progress we will see. What matters is that we
// don't see a measurement submission, since it means
// that we have interrupted the measurement.
if keys == nil || keys[len(keys)-1] != event.Key {
keys = append(keys, event.Key)
}
}
expect := []string{
"status.queued",
"status.started",
"status.progress",
"status.geoip_lookup",
"status.resolver_lookup",
"status.progress",
"status.report_create",
"status.measurement_start",
"status.progress",
"status.end",
"task_terminated",
}
if diff := cmp.Diff(expect, keys); diff != "" {
t.Fatal(diff)
}
}
func TestCountBytesForExample(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
task, err := oonimkall.StartTask(`{
"assets_dir": "../testdata/oonimkall/assets",
"name": "Example",
"options": {
"software_name": "oonimkall-test",
"software_version": "0.1.0"
},
"state_dir": "../testdata/oonimkall/state",
"version": 1
}`)
if err != nil {
t.Fatal(err)
}
var downloadKB, uploadKB float64
for !task.IsDone() {
eventstr := task.WaitForNextEvent()
var event eventlike
if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
t.Fatal(err)
}
switch event.Key {
case "failure.startup":
t.Fatal(eventstr)
case "status.end":
downloadKB = event.Value["downloaded_kb"].(float64)
uploadKB = event.Value["uploaded_kb"].(float64)
}
}
if downloadKB == 0 {
t.Fatal("downloadKB is zero")
}
if uploadKB == 0 {
t.Fatal("uploadKB is zero")
}
}
func TestPrivacyAndScrubbing(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
task, err := oonimkall.StartTask(`{
"assets_dir": "../testdata/oonimkall/assets",
"name": "Example",
"options": {
"software_name": "oonimkall-test",
"software_version": "0.1.0"
},
"state_dir": "../testdata/oonimkall/state",
"version": 1
}`)
if err != nil {
t.Fatal(err)
}
var m *model.Measurement
for !task.IsDone() {
eventstr := task.WaitForNextEvent()
var event eventlike
if err := json.Unmarshal([]byte(eventstr), &event); err != nil {
t.Fatal(err)
}
switch event.Key {
case "failure.startup":
t.Fatal(eventstr)
case "measurement":
v := []byte(event.Value["json_str"].(string))
m = new(model.Measurement)
if err := json.Unmarshal(v, &m); err != nil {
t.Fatal(err)
}
}
}
if m == nil {
t.Fatal("measurement is nil")
}
if m.ProbeASN == "AS0" || m.ProbeCC == "ZZ" || m.ProbeIP != "127.0.0.1" {
t.Fatal("unexpected result")
}
}
func TestNonblock(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
task, err := oonimkall.StartTask(`{
"assets_dir": "../testdata/oonimkall/assets",
"name": "Example",
"options": {
"software_name": "oonimkall-test",
"software_version": "0.1.0"
},
"state_dir": "../testdata/oonimkall/state",
"version": 1
}`)
if err != nil {
t.Fatal(err)
}
if !task.IsRunning() {
t.Fatal("The runner should be running at this point")
}
// If the task blocks because it emits too much events, this test
// will run forever and will be killed. Because we have room for up
// to 128 events in the buffer, we should hopefully be fine.
for task.IsRunning() {
time.Sleep(time.Second)
}
for !task.IsDone() {
task.WaitForNextEvent()
}
}
+5
View File
@@ -0,0 +1,5 @@
package oonimkall
func (t *Task) IsRunning() bool {
return t.isstopped.Load() == 0
}
+87
View File
@@ -0,0 +1,87 @@
package tasks
import (
"fmt"
)
// ChanLogger is a logger targeting a channel
type ChanLogger struct {
emitter *EventEmitter
hasdebug bool
hasinfo bool
haswarning bool
out chan<- *Event
}
// Debug implements Logger.Debug
func (cl *ChanLogger) Debug(msg string) {
if cl.hasdebug {
cl.emitter.Emit("log", EventLog{
LogLevel: "DEBUG",
Message: msg,
})
}
}
// Debugf implements Logger.Debugf
func (cl *ChanLogger) Debugf(format string, v ...interface{}) {
if cl.hasdebug {
cl.Debug(fmt.Sprintf(format, v...))
}
}
// Info implements Logger.Info
func (cl *ChanLogger) Info(msg string) {
if cl.hasinfo {
cl.emitter.Emit("log", EventLog{
LogLevel: "INFO",
Message: msg,
})
}
}
// Infof implements Logger.Infof
func (cl *ChanLogger) Infof(format string, v ...interface{}) {
if cl.hasinfo {
cl.Info(fmt.Sprintf(format, v...))
}
}
// Warn implements Logger.Warn
func (cl *ChanLogger) Warn(msg string) {
if cl.haswarning {
cl.emitter.Emit("log", EventLog{
LogLevel: "WARNING",
Message: msg,
})
}
}
// Warnf implements Logger.Warnf
func (cl *ChanLogger) Warnf(format string, v ...interface{}) {
if cl.haswarning {
cl.Warn(fmt.Sprintf(format, v...))
}
}
// NewChanLogger creates a new ChanLogger instance.
func NewChanLogger(emitter *EventEmitter, logLevel string,
out chan<- *Event) *ChanLogger {
cl := &ChanLogger{
emitter: emitter,
out: out,
}
switch logLevel {
case "DEBUG", "DEBUG2":
cl.hasdebug = true
fallthrough
case "INFO":
cl.hasinfo = true
fallthrough
case "ERR", "WARNING":
fallthrough
default:
cl.haswarning = true
}
return cl
}
+57
View File
@@ -0,0 +1,57 @@
package tasks
type eventEmpty struct{}
// EventFailure contains information on a failure.
type EventFailure struct {
Failure string `json:"failure"`
}
// EventLog is an event containing a log message.
type EventLog struct {
LogLevel string `json:"log_level"`
Message string `json:"message"`
}
type eventMeasurementGeneric struct {
Failure string `json:"failure,omitempty"`
Idx int64 `json:"idx"`
Input string `json:"input"`
JSONStr string `json:"json_str,omitempty"`
}
type eventStatusEnd struct {
DownloadedKB float64 `json:"downloaded_kb"`
Failure string `json:"failure"`
UploadedKB float64 `json:"uploaded_kb"`
}
type eventStatusGeoIPLookup struct {
ProbeASN string `json:"probe_asn"`
ProbeCC string `json:"probe_cc"`
ProbeIP string `json:"probe_ip"`
ProbeNetworkName string `json:"probe_network_name"`
}
// EventStatusProgress reports progress information.
type EventStatusProgress struct {
Message string `json:"message"`
Percentage float64 `json:"percentage"`
}
type eventStatusReportGeneric struct {
ReportID string `json:"report_id"`
}
type eventStatusResolverLookup struct {
ResolverASN string `json:"resolver_asn"`
ResolverIP string `json:"resolver_ip"`
ResolverNetworkName string `json:"resolver_network_name"`
}
// Event is an event emitted by a task. This structure extends the event
// described by MK v0.10.9 FFI API (https://git.io/Jv4Rv).
type Event struct {
Key string `json:"key"`
Value interface{} `json:"value"`
}
+40
View File
@@ -0,0 +1,40 @@
package tasks
// EventEmitter emits event on a channel
type EventEmitter struct {
disabled map[string]bool
out chan<- *Event
}
// NewEventEmitter creates a new Emitter
func NewEventEmitter(disabledEvents []string, out chan<- *Event) *EventEmitter {
ee := &EventEmitter{out: out}
ee.disabled = make(map[string]bool)
for _, eventname := range disabledEvents {
ee.disabled[eventname] = true
}
return ee
}
// EmitFailureStartup emits the failureStartup event
func (ee *EventEmitter) EmitFailureStartup(failure string) {
ee.EmitFailureGeneric(failureStartup, failure)
}
// EmitFailureGeneric emits a failure event
func (ee *EventEmitter) EmitFailureGeneric(name, failure string) {
ee.Emit(name, EventFailure{Failure: failure})
}
// EmitStatusProgress emits the status.Progress event
func (ee *EventEmitter) EmitStatusProgress(percentage float64, message string) {
ee.Emit(statusProgress, EventStatusProgress{Message: message, Percentage: percentage})
}
// Emit emits the specified event
func (ee *EventEmitter) Emit(key string, value interface{}) {
if ee.disabled[key] == true {
return
}
ee.out <- &Event{Key: key, Value: value}
}
+67
View File
@@ -0,0 +1,67 @@
package tasks_test
import (
"testing"
"github.com/ooni/probe-cli/v3/pkg/oonimkall/tasks"
)
func TestDisabledEvents(t *testing.T) {
out := make(chan *tasks.Event)
emitter := tasks.NewEventEmitter([]string{"log"}, out)
go func() {
emitter.Emit("log", tasks.EventLog{Message: "foo"})
close(out)
}()
var count int64
for ev := range out {
if ev.Key == "log" {
count++
}
}
if count > 0 {
t.Fatal("cannot disable events")
}
}
func TestEmitFailureStartup(t *testing.T) {
out := make(chan *tasks.Event)
emitter := tasks.NewEventEmitter([]string{}, out)
go func() {
emitter.EmitFailureStartup("mocked error")
close(out)
}()
var found bool
for ev := range out {
if ev.Key == "failure.startup" {
evv := ev.Value.(tasks.EventFailure) // panic if not castable
if evv.Failure == "mocked error" {
found = true
}
}
}
if !found {
t.Fatal("did not see expected event")
}
}
func TestEmitStatusProgress(t *testing.T) {
out := make(chan *tasks.Event)
emitter := tasks.NewEventEmitter([]string{}, out)
go func() {
emitter.EmitStatusProgress(0.7, "foo")
close(out)
}()
var found bool
for ev := range out {
if ev.Key == "status.progress" {
evv := ev.Value.(tasks.EventStatusProgress) // panic if not castable
if evv.Message == "foo" && evv.Percentage == 0.7 {
found = true
}
}
}
if !found {
t.Fatal("did not see expected event")
}
}
+299
View File
@@ -0,0 +1,299 @@
// Package tasks implements tasks run using the oonimkall API.
package tasks
import (
"context"
"encoding/json"
"fmt"
"time"
engine "github.com/ooni/probe-cli/v3/internal/engine"
"github.com/ooni/probe-cli/v3/internal/engine/runtimex"
"github.com/ooni/probe-cli/v3/internal/engine/model"
)
const (
failureIPLookup = "failure.ip_lookup"
failureASNLookup = "failure.asn_lookup"
failureCCLookup = "failure.cc_lookup"
failureMeasurement = "failure.measurement"
failureMeasurementSubmission = "failure.measurement_submission"
failureReportCreate = "failure.report_create"
failureResolverLookup = "failure.resolver_lookup"
failureStartup = "failure.startup"
measurement = "measurement"
statusEnd = "status.end"
statusGeoIPLookup = "status.geoip_lookup"
statusMeasurementDone = "status.measurement_done"
statusMeasurementStart = "status.measurement_start"
statusMeasurementSubmission = "status.measurement_submission"
statusProgress = "status.progress"
statusQueued = "status.queued"
statusReportCreate = "status.report_create"
statusResolverLookup = "status.resolver_lookup"
statusStarted = "status.started"
)
// Run runs the task specified by settings.Name until completion. This is the
// top-level API that should be called by oonimkall.
func Run(ctx context.Context, settings *Settings, out chan<- *Event) {
r := NewRunner(settings, out)
r.Run(ctx)
}
// Runner runs a specific task
type Runner struct {
emitter *EventEmitter
maybeLookupLocation func(*engine.Session) error
out chan<- *Event
settings *Settings
}
// NewRunner creates a new task runner
func NewRunner(settings *Settings, out chan<- *Event) *Runner {
return &Runner{
emitter: NewEventEmitter(settings.DisabledEvents, out),
out: out,
settings: settings,
}
}
// FailureInvalidVersion is the failure returned when Version is invalid
const FailureInvalidVersion = "invalid Settings.Version number"
func (r *Runner) hasUnsupportedSettings(logger *ChanLogger) bool {
if r.settings.Version < 1 {
r.emitter.EmitFailureStartup(FailureInvalidVersion)
return true
}
return false
}
func (r *Runner) newsession(logger *ChanLogger) (*engine.Session, error) {
kvstore, err := engine.NewFileSystemKVStore(r.settings.StateDir)
if err != nil {
return nil, err
}
config := engine.SessionConfig{
AssetsDir: r.settings.AssetsDir,
KVStore: kvstore,
Logger: logger,
SoftwareName: r.settings.Options.SoftwareName,
SoftwareVersion: r.settings.Options.SoftwareVersion,
TempDir: r.settings.TempDir,
}
if r.settings.Options.ProbeServicesBaseURL != "" {
config.AvailableProbeServices = []model.Service{{
Type: "https",
Address: r.settings.Options.ProbeServicesBaseURL,
}}
}
return engine.NewSession(config)
}
func (r *Runner) contextForExperiment(
ctx context.Context, builder *engine.ExperimentBuilder,
) context.Context {
if builder.Interruptible() {
return ctx
}
return context.Background()
}
type runnerCallbacks struct {
emitter *EventEmitter
}
func (cb *runnerCallbacks) OnProgress(percentage float64, message string) {
cb.emitter.Emit(statusProgress, EventStatusProgress{
Percentage: 0.4 + (percentage * 0.6), // open report is 40%
Message: message,
})
}
// Run runs the runner until completion. The context argument controls
// when to stop when processing multiple inputs, as well as when to stop
// experiments explicitly marked as interruptible.
func (r *Runner) Run(ctx context.Context) {
logger := NewChanLogger(r.emitter, r.settings.LogLevel, r.out)
r.emitter.Emit(statusQueued, eventEmpty{})
if r.hasUnsupportedSettings(logger) {
return
}
r.emitter.Emit(statusStarted, eventEmpty{})
sess, err := r.newsession(logger)
if err != nil {
r.emitter.EmitFailureStartup(err.Error())
return
}
endEvent := new(eventStatusEnd)
defer func() {
sess.Close()
r.emitter.Emit(statusEnd, endEvent)
}()
builder, err := sess.NewExperimentBuilder(r.settings.Name)
if err != nil {
r.emitter.EmitFailureStartup(err.Error())
return
}
logger.Info("Looking up OONI backends... please, be patient")
if err := sess.MaybeLookupBackends(); err != nil {
r.emitter.EmitFailureStartup(err.Error())
return
}
r.emitter.EmitStatusProgress(0.1, "contacted bouncer")
logger.Info("Looking up your location... please, be patient")
maybeLookupLocation := r.maybeLookupLocation
if maybeLookupLocation == nil {
maybeLookupLocation = func(sess *engine.Session) error {
return sess.MaybeLookupLocation()
}
}
if err := maybeLookupLocation(sess); err != nil {
r.emitter.EmitFailureGeneric(failureIPLookup, err.Error())
r.emitter.EmitFailureGeneric(failureASNLookup, err.Error())
r.emitter.EmitFailureGeneric(failureCCLookup, err.Error())
r.emitter.EmitFailureGeneric(failureResolverLookup, err.Error())
return
}
r.emitter.EmitStatusProgress(0.2, "geoip lookup")
r.emitter.EmitStatusProgress(0.3, "resolver lookup")
r.emitter.Emit(statusGeoIPLookup, eventStatusGeoIPLookup{
ProbeIP: sess.ProbeIP(),
ProbeASN: sess.ProbeASNString(),
ProbeCC: sess.ProbeCC(),
ProbeNetworkName: sess.ProbeNetworkName(),
})
r.emitter.Emit(statusResolverLookup, eventStatusResolverLookup{
ResolverASN: sess.ResolverASNString(),
ResolverIP: sess.ResolverIP(),
ResolverNetworkName: sess.ResolverNetworkName(),
})
builder.SetCallbacks(&runnerCallbacks{emitter: r.emitter})
if len(r.settings.Inputs) <= 0 {
switch builder.InputPolicy() {
case engine.InputOrQueryTestLists, engine.InputStrictlyRequired:
r.emitter.EmitFailureStartup("no input provided")
return
}
r.settings.Inputs = append(r.settings.Inputs, "")
}
experiment := builder.NewExperiment()
defer func() {
endEvent.DownloadedKB = experiment.KibiBytesReceived()
endEvent.UploadedKB = experiment.KibiBytesSent()
}()
if !r.settings.Options.NoCollector {
logger.Info("Opening report... please, be patient")
if err := experiment.OpenReport(); err != nil {
r.emitter.EmitFailureGeneric(failureReportCreate, err.Error())
return
}
r.emitter.EmitStatusProgress(0.4, "open report")
r.emitter.Emit(statusReportCreate, eventStatusReportGeneric{
ReportID: experiment.ReportID(),
})
}
// This deviates a little bit from measurement-kit, for which
// a zero timeout is actually valid. Since it does not make much
// sense, here we're changing the behaviour.
//
// See https://github.com/measurement-kit/measurement-kit/issues/1922
if r.settings.Options.MaxRuntime > 0 {
// We want to honour max_runtime only when we're running an
// experiment that clearly wants specific input. We could refine
// this policy in the future, but for now this covers in a
// reasonable way web connectivity, so we should be ok.
switch builder.InputPolicy() {
case engine.InputOrQueryTestLists, engine.InputStrictlyRequired:
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(
ctx, time.Duration(r.settings.Options.MaxRuntime)*time.Second,
)
defer cancel()
}
}
inputCount := len(r.settings.Inputs)
start := time.Now()
inflatedMaxRuntime := r.settings.Options.MaxRuntime + r.settings.Options.MaxRuntime/10
eta := start.Add(time.Duration(inflatedMaxRuntime) * time.Second)
for idx, input := range r.settings.Inputs {
if ctx.Err() != nil {
break
}
logger.Infof("Starting measurement with index %d", idx)
r.emitter.Emit(statusMeasurementStart, eventMeasurementGeneric{
Idx: int64(idx),
Input: input,
})
if input != "" && inputCount > 0 {
var percentage float64
if r.settings.Options.MaxRuntime > 0 {
now := time.Now()
percentage = (now.Sub(start).Seconds()/eta.Sub(start).Seconds())*0.6 + 0.4
} else {
percentage = (float64(idx)/float64(inputCount))*0.6 + 0.4
}
r.emitter.EmitStatusProgress(percentage, fmt.Sprintf(
"processing %s", input,
))
}
m, err := experiment.MeasureWithContext(
r.contextForExperiment(ctx, builder),
input,
)
if builder.Interruptible() && ctx.Err() != nil {
// We want to stop here only if interruptible otherwise we want to
// submit measurement and stop at beginning of next iteration
break
}
m.AddAnnotations(r.settings.Annotations)
if err != nil {
r.emitter.Emit(failureMeasurement, eventMeasurementGeneric{
Failure: err.Error(),
Idx: int64(idx),
Input: input,
})
// fallthrough: we want to submit the report anyway
}
data, err := json.Marshal(m)
runtimex.PanicOnError(err, "measurement.MarshalJSON failed")
r.emitter.Emit(measurement, eventMeasurementGeneric{
Idx: int64(idx),
Input: input,
JSONStr: string(data),
})
if !r.settings.Options.NoCollector {
logger.Info("Submitting measurement... please, be patient")
err := experiment.SubmitAndUpdateMeasurement(m)
r.emitter.Emit(measurementSubmissionEventName(err), eventMeasurementGeneric{
Idx: int64(idx),
Input: input,
JSONStr: string(data),
Failure: measurementSubmissionFailure(err),
})
}
r.emitter.Emit(statusMeasurementDone, eventMeasurementGeneric{
Idx: int64(idx),
Input: input,
})
}
}
func measurementSubmissionEventName(err error) string {
if err != nil {
return failureMeasurementSubmission
}
return statusMeasurementSubmission
}
func measurementSubmissionFailure(err error) string {
if err != nil {
return err.Error()
}
return ""
}
@@ -0,0 +1,404 @@
package tasks_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/ooni/probe-cli/v3/pkg/oonimkall/tasks"
)
func TestRunnerMaybeLookupBackendsFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
}))
defer server.Close()
out := make(chan *tasks.Event)
settings := &tasks.Settings{
AssetsDir: "../../testdata/oonimkall/assets",
Name: "Example",
Options: tasks.SettingsOptions{
ProbeServicesBaseURL: server.URL,
SoftwareName: "oonimkall-test",
SoftwareVersion: "0.1.0",
},
StateDir: "../../testdata/oonimkall/state",
Version: 1,
}
go func() {
tasks.Run(context.Background(), settings, out)
close(out)
}()
var failures []string
for ev := range out {
switch ev.Key {
case "failure.startup":
failure := ev.Value.(tasks.EventFailure).Failure
failures = append(failures, failure)
case "status.queued", "status.started", "log", "status.end":
default:
panic(fmt.Sprintf("unexpected key: %s", ev.Key))
}
}
if len(failures) != 1 {
t.Fatal("unexpected number of failures")
}
if failures[0] != "all available probe services failed" {
t.Fatal("invalid failure")
}
}
func TestRunnerOpenReportFailure(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
var (
nreq int64
mu sync.Mutex
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
nreq++
if nreq == 1 {
w.Write([]byte(`{}`))
return
}
w.WriteHeader(500)
}))
defer server.Close()
out := make(chan *tasks.Event)
settings := &tasks.Settings{
AssetsDir: "../../testdata/oonimkall/assets",
Name: "Example",
Options: tasks.SettingsOptions{
ProbeServicesBaseURL: server.URL,
SoftwareName: "oonimkall-test",
SoftwareVersion: "0.1.0",
},
StateDir: "../../testdata/oonimkall/state",
Version: 1,
}
seench := make(chan int64)
go func() {
var seen int64
for ev := range out {
switch ev.Key {
case "failure.report_create":
seen++
case "status.progress":
evv := ev.Value.(tasks.EventStatusProgress)
if evv.Percentage >= 0.4 {
panic(fmt.Sprintf("too much progress: %+v", ev))
}
case "status.queued", "status.started", "log", "status.end",
"status.geoip_lookup", "status.resolver_lookup":
default:
panic(fmt.Sprintf("unexpected key: %s", ev.Key))
}
}
seench <- seen
}()
tasks.Run(context.Background(), settings, out)
close(out)
if n := <-seench; n != 1 {
t.Fatal("unexpected number of events")
}
}
func TestRunnerGood(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
out := make(chan *tasks.Event)
settings := &tasks.Settings{
AssetsDir: "../../testdata/oonimkall/assets",
LogLevel: "DEBUG",
Name: "Example",
Options: tasks.SettingsOptions{
SoftwareName: "oonimkall-test",
SoftwareVersion: "0.1.0",
},
StateDir: "../../testdata/oonimkall/state",
Version: 1,
}
go func() {
tasks.Run(context.Background(), settings, out)
close(out)
}()
var found bool
for ev := range out {
if ev.Key == "status.end" {
found = true
}
}
if !found {
t.Fatal("status.end event not found")
}
}
func TestRunnerWithUnsupportedSettings(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
out := make(chan *tasks.Event)
settings := &tasks.Settings{
AssetsDir: "../../testdata/oonimkall/assets",
LogLevel: "DEBUG",
Name: "Example",
Options: tasks.SettingsOptions{
SoftwareName: "oonimkall-test",
SoftwareVersion: "0.1.0",
},
StateDir: "../../testdata/oonimkall/state",
}
go func() {
tasks.Run(context.Background(), settings, out)
close(out)
}()
var failures []string
for ev := range out {
if ev.Key == "failure.startup" {
failure := ev.Value.(tasks.EventFailure).Failure
failures = append(failures, failure)
}
}
if len(failures) != 1 {
t.Fatal("invalid number of failures")
}
if failures[0] != tasks.FailureInvalidVersion {
t.Fatal("not the failure we expected")
}
}
func TestRunnerWithInvalidKVStorePath(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
out := make(chan *tasks.Event)
settings := &tasks.Settings{
AssetsDir: "../../testdata/oonimkall/assets",
LogLevel: "DEBUG",
Name: "Example",
Options: tasks.SettingsOptions{
SoftwareName: "oonimkall-test",
SoftwareVersion: "0.1.0",
},
StateDir: "", // must be empty to cause the failure below
Version: 1,
}
go func() {
tasks.Run(context.Background(), settings, out)
close(out)
}()
var failures []string
for ev := range out {
if ev.Key == "failure.startup" {
failure := ev.Value.(tasks.EventFailure).Failure
failures = append(failures, failure)
}
}
if len(failures) != 1 {
t.Fatal("invalid number of failures")
}
if failures[0] != "mkdir : no such file or directory" {
t.Fatal("not the failure we expected")
}
}
func TestRunnerWithInvalidExperimentName(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
out := make(chan *tasks.Event)
settings := &tasks.Settings{
AssetsDir: "../../testdata/oonimkall/assets",
LogLevel: "DEBUG",
Name: "Nonexistent",
Options: tasks.SettingsOptions{
SoftwareName: "oonimkall-test",
SoftwareVersion: "0.1.0",
},
StateDir: "../../testdata/oonimkall/state",
Version: 1,
}
go func() {
tasks.Run(context.Background(), settings, out)
close(out)
}()
var failures []string
for ev := range out {
if ev.Key == "failure.startup" {
failure := ev.Value.(tasks.EventFailure).Failure
failures = append(failures, failure)
}
}
if len(failures) != 1 {
t.Fatal("invalid number of failures")
}
if failures[0] != "no such experiment: Nonexistent" {
t.Fatalf("not the failure we expected: %s", failures[0])
}
}
func TestRunnerWithMissingInput(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
out := make(chan *tasks.Event)
settings := &tasks.Settings{
AssetsDir: "../../testdata/oonimkall/assets",
LogLevel: "DEBUG",
Name: "ExampleWithInput",
Options: tasks.SettingsOptions{
SoftwareName: "oonimkall-test",
SoftwareVersion: "0.1.0",
},
StateDir: "../../testdata/oonimkall/state",
Version: 1,
}
go func() {
tasks.Run(context.Background(), settings, out)
close(out)
}()
var failures []string
for ev := range out {
if ev.Key == "failure.startup" {
failure := ev.Value.(tasks.EventFailure).Failure
failures = append(failures, failure)
}
}
if len(failures) != 1 {
t.Fatal("invalid number of failures")
}
if failures[0] != "no input provided" {
t.Fatalf("not the failure we expected: %s", failures[0])
}
}
func TestRunnerWithMaxRuntime(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
out := make(chan *tasks.Event)
settings := &tasks.Settings{
AssetsDir: "../../testdata/oonimkall/assets",
Inputs: []string{"a", "b", "c", "d", "e", "f", "g", "h", "i"},
LogLevel: "DEBUG",
Name: "ExampleWithInput",
Options: tasks.SettingsOptions{
MaxRuntime: 1,
SoftwareName: "oonimkall-test",
SoftwareVersion: "0.1.0",
},
StateDir: "../../testdata/oonimkall/state",
Version: 1,
}
begin := time.Now()
go func() {
tasks.Run(context.Background(), settings, out)
close(out)
}()
var found bool
for ev := range out {
if ev.Key == "status.end" {
found = true
}
}
if !found {
t.Fatal("status.end event not found")
}
// The runtime is long because of ancillary operations and is even more
// longer because of self shaping we may be performing (especially in
// CI builds) using `-tags shaping`). We have experimentally determined
// that ~10 seconds is the typical CI test run time. See:
//
// 1. https://github.com/ooni/probe-cli/v3/internal/engine/pull/588/checks?check_run_id=667263788
//
// 2. https://github.com/ooni/probe-cli/v3/internal/engine/pull/588/checks?check_run_id=667263855
if time.Now().Sub(begin) > 10*time.Second {
t.Fatal("expected shorter runtime")
}
}
func TestRunnerWithMaxRuntimeNonInterruptible(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
out := make(chan *tasks.Event)
settings := &tasks.Settings{
AssetsDir: "../../testdata/oonimkall/assets",
Inputs: []string{"a", "b", "c", "d", "e", "f", "g", "h", "i"},
LogLevel: "DEBUG",
Name: "ExampleWithInputNonInterruptible",
Options: tasks.SettingsOptions{
MaxRuntime: 1,
SoftwareName: "oonimkall-test",
SoftwareVersion: "0.1.0",
},
StateDir: "../../testdata/oonimkall/state",
Version: 1,
}
begin := time.Now()
go func() {
tasks.Run(context.Background(), settings, out)
close(out)
}()
var found bool
for ev := range out {
if ev.Key == "status.end" {
found = true
}
}
if !found {
t.Fatal("status.end event not found")
}
// The runtime is long because of ancillary operations and is even more
// longer because of self shaping we may be performing (especially in
// CI builds) using `-tags shaping`). We have experimentally determined
// that ~10 seconds is the typical CI test run time. See:
//
// 1. https://github.com/ooni/probe-cli/v3/internal/engine/pull/588/checks?check_run_id=667263788
//
// 2. https://github.com/ooni/probe-cli/v3/internal/engine/pull/588/checks?check_run_id=667263855
if time.Now().Sub(begin) > 10*time.Second {
t.Fatal("expected shorter runtime")
}
}
func TestRunnerWithFailedMeasurement(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
out := make(chan *tasks.Event)
settings := &tasks.Settings{
AssetsDir: "../../testdata/oonimkall/assets",
Inputs: []string{"a", "b", "c", "d", "e", "f", "g", "h", "i"},
LogLevel: "DEBUG",
Name: "ExampleWithFailure",
Options: tasks.SettingsOptions{
MaxRuntime: 1,
SoftwareName: "oonimkall-test",
SoftwareVersion: "0.1.0",
},
StateDir: "../../testdata/oonimkall/state",
Version: 1,
}
go func() {
tasks.Run(context.Background(), settings, out)
close(out)
}()
var found bool
for ev := range out {
if ev.Key == "failure.measurement" {
found = true
}
}
if !found {
t.Fatal("failure.measurement event not found")
}
}
@@ -0,0 +1,72 @@
package tasks
import (
"context"
"errors"
"fmt"
"testing"
engine "github.com/ooni/probe-cli/v3/internal/engine"
)
func TestMeasurementSubmissionEventName(t *testing.T) {
if measurementSubmissionEventName(nil) != statusMeasurementSubmission {
t.Fatal("unexpected submission event name")
}
if measurementSubmissionEventName(errors.New("mocked error")) != failureMeasurementSubmission {
t.Fatal("unexpected submission event name")
}
}
func TestMeasurementSubmissionFailure(t *testing.T) {
if measurementSubmissionFailure(nil) != "" {
t.Fatal("unexpected submission failure")
}
if measurementSubmissionFailure(errors.New("mocked error")) != "mocked error" {
t.Fatal("unexpected submission failure")
}
}
func TestRunnerMaybeLookupLocationFailure(t *testing.T) {
out := make(chan *Event)
settings := &Settings{
AssetsDir: "../../testdata/oonimkall/assets",
Name: "Example",
Options: SettingsOptions{
SoftwareName: "oonimkall-test",
SoftwareVersion: "0.1.0",
},
StateDir: "../../testdata/oonimkall/state",
Version: 1,
}
seench := make(chan int64)
go func() {
var seen int64
for ev := range out {
switch ev.Key {
case "failure.ip_lookup", "failure.asn_lookup",
"failure.cc_lookup", "failure.resolver_lookup":
seen++
case "status.progress":
evv := ev.Value.(EventStatusProgress)
if evv.Percentage >= 0.2 {
panic(fmt.Sprintf("too much progress: %+v", ev))
}
case "status.queued", "status.started", "status.end":
default:
panic(fmt.Sprintf("unexpected key: %s", ev.Key))
}
}
seench <- seen
}()
expected := errors.New("mocked error")
r := NewRunner(settings, out)
r.maybeLookupLocation = func(*engine.Session) error {
return expected
}
r.Run(context.Background())
close(out)
if n := <-seench; n != 4 {
t.Fatal("unexpected number of events")
}
}
+73
View File
@@ -0,0 +1,73 @@
package tasks
// Settings contains settings for a task. This structure derives from
// the one described by MK v0.10.9 FFI API (https://git.io/Jv4Rv), yet
// since 2020-12-03 we're not backwards compatible anymore.
type Settings struct {
// Annotations contains the annotations to be added
// to every measurements performed by the task.
Annotations map[string]string `json:"annotations,omitempty"`
// AssetsDir is the directory where to store assets. This
// field is an extension of MK's specification. If
// this field is empty, the task won't start.
AssetsDir string `json:"assets_dir"`
// DisabledEvents contains disabled events. See
// https://git.io/Jv4Rv for the events names.
DisabledEvents []string `json:"disabled_events,omitempty"`
// Inputs contains the inputs. The task will fail if it
// requires input and you provide no input.
Inputs []string `json:"inputs,omitempty"`
// LogLevel contains the logs level. See https://git.io/Jv4Rv
// for the names of the available log levels.
LogLevel string `json:"log_level,omitempty"`
// Name contains the task name. By https://git.io/Jv4Rv the
// names are in camel case, e.g. `Ndt`.
Name string `json:"name"`
// Options contains the task options.
Options SettingsOptions `json:"options"`
// StateDir is the directory where to store persistent data. This
// field is an extension of MK's specification. If
// this field is empty, the task won't start.
StateDir string `json:"state_dir"`
// TempDir is the temporary directory. This field is an extension of MK's
// specification. If this field is empty, we will pick the tempdir that
// ioutil.TempDir uses by default, which may not work on mobile. According
// to our experiments as of 2020-06-10, leaving the TempDir empty works
// for iOS and does not work for Android.
TempDir string `json:"temp_dir"`
// Version indicates the version of this structure.
Version int64 `json:"version"`
}
// SettingsOptions contains the settings options
type SettingsOptions struct {
// MaxRuntime is the maximum runtime expressed in seconds. A negative
// value for this field disables the maximum runtime. Using
// a zero value will also mean disabled. This is not the
// original behaviour of Measurement Kit, which used to run
// for zero time in such case.
MaxRuntime float64 `json:"max_runtime,omitempty"`
// NoCollector indicates whether to use a collector
NoCollector bool `json:"no_collector,omitempty"`
// ProbeServicesBaseURL contains the probe services base URL.
ProbeServicesBaseURL string `json:"probe_services_base_url,omitempty"`
// SoftwareName is the software name. If this option is not
// present, then the library startup will fail.
SoftwareName string `json:"software_name,omitempty"`
// SoftwareVersion is the software version. If this option is not
// present, then the library startup will fail.
SoftwareVersion string `json:"software_version,omitempty"`
}
+9
View File
@@ -0,0 +1,9 @@
package oonimkall
import "github.com/google/uuid"
// NewUUID4 generates a new UUID4 string. This functionality is typically
// used by mobile apps to generate random unique identifiers.
func NewUUID4() string {
return uuid.Must(uuid.NewRandom()).String()
}
+13
View File
@@ -0,0 +1,13 @@
package oonimkall_test
import (
"testing"
"github.com/ooni/probe-cli/v3/pkg/oonimkall"
)
func TestNewUUID4(t *testing.T) {
if out := oonimkall.NewUUID4(); len(out) != 36 {
t.Fatal("not the expected output")
}
}