feat(miniooni): make CLI much more user friendly (#913)

Part of https://github.com/ooni/probe/issues/2184, because I wanted
to allow swapping commands and options more freely.

As a side effect, this PR closes https://github.com/ooni/probe/issues/2248.

AFAICT, every usage that was legal before is still legal. What has
changed seems the freedom to swap commands and options and a much
better help that lists the available options.
This commit is contained in:
Simone Basso
2022-08-31 12:44:46 +02:00
committed by GitHub
parent 7daa686c68
commit 0bc6aae601
38 changed files with 280 additions and 139 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ import (
// acquireUserConsent ensures the user is okay with using miniooni. This function
// panics if we do not have acquired the user consent.
func acquireUserConsent(miniooniDir string, currentOptions Options) {
func acquireUserConsent(miniooniDir string, currentOptions *Options) {
consentFile := path.Join(miniooniDir, "informed")
err := maybeWriteConsentFile(currentOptions.Yes, consentFile)
runtimex.PanicOnError(err, "cannot write informed consent file")
+230 -101
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path"
"strings"
"time"
"github.com/apex/log"
@@ -14,9 +15,10 @@ import (
"github.com/ooni/probe-cli/v3/internal/humanize"
"github.com/ooni/probe-cli/v3/internal/legacy/assetsdir"
"github.com/ooni/probe-cli/v3/internal/model"
"github.com/ooni/probe-cli/v3/internal/registry"
"github.com/ooni/probe-cli/v3/internal/runtimex"
"github.com/ooni/probe-cli/v3/internal/version"
"github.com/pborman/getopt/v2"
"github.com/spf13/cobra"
)
// Options contains the options you can set from the CLI.
@@ -38,118 +40,227 @@ type Options struct {
TorBinary string
Tunnel string
Verbose bool
Version bool
Yes bool
}
var globalOptions Options
func init() {
getopt.FlagLong(
&globalOptions.Annotations, "annotation", 'A', "Add annotaton", "KEY=VALUE",
)
getopt.FlagLong(
&globalOptions.ExtraOptions, "option", 'O',
"Pass an option to the experiment", "KEY=VALUE",
)
getopt.FlagLong(
&globalOptions.InputFilePaths, "input-file", 'f',
"Path to input file to supply test-dependent input. File must contain one input per line.", "PATH",
)
getopt.FlagLong(
&globalOptions.HomeDir, "home", 0,
"Force specific home directory", "PATH",
)
getopt.FlagLong(
&globalOptions.Inputs, "input", 'i',
"Add test-dependent input to the test input", "INPUT",
)
getopt.FlagLong(
&globalOptions.MaxRuntime, "max-runtime", 0,
"Maximum runtime in seconds when looping over a list of inputs (zero means infinite)", "N",
)
getopt.FlagLong(
&globalOptions.NoJSON, "no-json", 'N', "Disable writing to disk",
)
getopt.FlagLong(
&globalOptions.NoCollector, "no-collector", 'n', "Don't use a collector",
)
getopt.FlagLong(
&globalOptions.ProbeServicesURL, "probe-services", 0,
"Set the URL of the probe-services instance you want to use", "URL",
)
getopt.FlagLong(
&globalOptions.Proxy, "proxy", 0, "Set the proxy URL", "URL",
)
getopt.FlagLong(
&globalOptions.Random, "random", 0, "Randomize inputs",
)
getopt.FlagLong(
&globalOptions.RepeatEvery, "repeat-every", 0,
"Repeat the measurement every INTERVAL number of seconds", "INTERVAL",
)
getopt.FlagLong(
&globalOptions.ReportFile, "reportfile", 'o',
"Set the report file path", "PATH",
)
getopt.FlagLong(
&globalOptions.TorArgs, "tor-args", 0,
"Extra args for tor binary (may be specified multiple times)",
)
getopt.FlagLong(
&globalOptions.TorBinary, "tor-binary", 0,
"Specify path to a specific tor binary",
)
getopt.FlagLong(
&globalOptions.Tunnel, "tunnel", 0,
"Name of the tunnel to use (one of `tor`, `psiphon`)",
)
getopt.FlagLong(
&globalOptions.Verbose, "verbose", 'v', "Increase verbosity",
)
getopt.FlagLong(
&globalOptions.Version, "version", 0, "Print version and exit",
)
getopt.FlagLong(
&globalOptions.Yes, "yes", 'y',
"Assume yes as the answer to all questions",
)
}
// main is the main function of miniooni. This function parses the command line
// options and uses a global state. Use MainWithConfiguration if you want to avoid
// using any global state and relying on command line options.
//
// This function will panic in case of a fatal error. It is up to you that
// integrate this function to either handle the panic of ignore it.
// main is the main function of miniooni.
func main() {
getopt.Parse()
if globalOptions.Version {
fmt.Printf("%s\n", version.Version)
os.Exit(0)
var globalOptions Options
rootCmd := &cobra.Command{
Use: "miniooni",
Short: "miniooni is OONI's research client",
Args: cobra.NoArgs,
Version: version.Version,
}
rootCmd.SetVersionTemplate("{{ .Version }}\n")
flags := rootCmd.PersistentFlags()
flags.StringSliceVarP(
&globalOptions.Annotations,
"annotation",
"A",
[]string{},
"add KEY=VALUE annotation to the report (can be repeated multiple times)",
)
flags.StringVar(
&globalOptions.HomeDir,
"home",
"",
"force specific home directory",
)
flags.BoolVarP(
&globalOptions.NoJSON,
"no-json",
"N",
false,
"disable writing to disk",
)
flags.BoolVarP(
&globalOptions.NoCollector,
"no-collector",
"n",
false,
"do not submit measurements to the OONI collector",
)
flags.StringVar(
&globalOptions.ProbeServicesURL,
"probe-services",
"",
"URL of the OONI backend instance you want to use",
)
flags.StringVar(
&globalOptions.Proxy,
"proxy",
"",
"set proxy URL to communicate with the OONI backend (mutually exclusive with --tunnel)",
)
flags.Int64Var(
&globalOptions.RepeatEvery,
"repeat-every",
0,
"wait the given number of seconds and then repeat the same measurement",
)
flags.StringVarP(
&globalOptions.ReportFile,
"reportfile",
"o",
"",
"set the output report file path (default: \"report.jsonl\")",
)
flags.StringSliceVar(
&globalOptions.TorArgs,
"tor-args",
[]string{},
"extra arguments for the tor binary (may be specified multiple times)",
)
flags.StringVar(
&globalOptions.TorBinary,
"tor-binary",
"",
"execute a specific tor binary",
)
flags.StringVar(
&globalOptions.Tunnel,
"tunnel",
"",
"tunnel to use to communicate with the OONI backend (one of: tor, psiphon)",
)
flags.BoolVarP(
&globalOptions.Verbose,
"verbose",
"v",
false,
"increase verbosity level",
)
flags.BoolVarP(
&globalOptions.Yes,
"yes",
"y",
false,
"assume yes as the answer to all questions",
)
rootCmd.MarkFlagsMutuallyExclusive("proxy", "tunnel")
registerAllExperiments(rootCmd, &globalOptions)
registerOONIRun(rootCmd, &globalOptions)
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
runtimex.PanicIfFalse(len(getopt.Args()) == 1, "Missing experiment name")
runtimex.PanicOnError(engine.CheckEmbeddedPsiphonConfig(), "Invalid embedded psiphon config")
MainWithConfiguration(getopt.Arg(0), globalOptions)
}
// tunnelAndProxy is the text printed when the user specifies
// both the --tunnel and the --proxy options
const tunnelAndProxy = `USAGE ERROR: The --tunnel option and the --proxy
option cannot be specified at the same time. The --tunnel option is actually
just syntactic sugar for --proxy. Setting --tunnel=psiphon is currently the
equivalent of setting --proxy=psiphon:///. This MAY change in a future version
of miniooni, when we will allow a tunnel to use a proxy.
`
// TODO(bassosimone): the current implementation is basically a cobra application
// where we hammered the previous miniooni code to make it work. We should
// obviously strive for more correctness. For example, it's a bit disgusting
// that MainWithConfiguration is invoked for both oonirun and random experiments.
// registerOONIRun registers the oonirun subcommand
func registerOONIRun(rootCmd *cobra.Command, globalOptions *Options) {
subCmd := &cobra.Command{
Use: "oonirun",
Short: "Runs a given OONI Run v2 link",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
MainWithConfiguration(cmd.Use, globalOptions)
},
}
rootCmd.AddCommand(subCmd)
flags := subCmd.Flags()
flags.StringSliceVarP(
&globalOptions.Inputs,
"input",
"i",
[]string{},
"URL of the OONI Run v2 descriptor to run (may be specified multiple times)",
)
}
// registerAllExperiments registers a subcommand for each experiment
func registerAllExperiments(rootCmd *cobra.Command, globalOptions *Options) {
for name, factory := range registry.AllExperiments {
subCmd := &cobra.Command{
Use: name,
Short: fmt.Sprintf("Runs the %s experiment", name),
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
MainWithConfiguration(cmd.Use, globalOptions)
},
}
rootCmd.AddCommand(subCmd)
flags := subCmd.Flags()
switch factory.InputPolicy() {
case model.InputOrQueryBackend,
model.InputStrictlyRequired,
model.InputOptional,
model.InputOrStaticDefault:
flags.StringSliceVarP(
&globalOptions.InputFilePaths,
"input-file",
"f",
[]string{},
"path to file to supply test dependent input (may be specified multiple times)",
)
flags.StringSliceVarP(
&globalOptions.Inputs,
"input",
"i",
[]string{},
"add test-dependent input (may be specified multiple times)",
)
flags.Int64Var(
&globalOptions.MaxRuntime,
"max-runtime",
0,
"maximum runtime in seconds for the experiment (zero means infinite)",
)
flags.BoolVar(
&globalOptions.Random,
"random",
false,
"randomize the inputs list",
)
default:
// nothing
}
if doc := documentationForOptions(name, factory); doc != "" {
flags.StringSliceVarP(
&globalOptions.ExtraOptions,
"option",
"O",
[]string{},
doc,
)
}
}
}
// MainWithConfiguration is the miniooni main with a specific configuration
// represented by the experiment name and the current options.
//
// This function will panic in case of a fatal error. It is up to you that
// integrate this function to either handle the panic of ignore it.
func MainWithConfiguration(experimentName string, currentOptions Options) {
runtimex.PanicIfTrue(currentOptions.Proxy != "" && currentOptions.Tunnel != "",
tunnelAndProxy)
func MainWithConfiguration(experimentName string, currentOptions *Options) {
runtimex.PanicOnError(engine.CheckEmbeddedPsiphonConfig(), "Invalid embedded psiphon config")
if currentOptions.Tunnel != "" {
currentOptions.Proxy = fmt.Sprintf("%s:///", currentOptions.Tunnel)
}
@@ -175,7 +286,7 @@ func MainWithConfiguration(experimentName string, currentOptions Options) {
// mainSingleIteration runs a single iteration. There may be multiple iterations
// when the user specifies the --repeat-every command line flag.
func mainSingleIteration(logger model.Logger, experimentName string, currentOptions Options) {
func mainSingleIteration(logger model.Logger, experimentName string, currentOptions *Options) {
extraOptions := mustMakeMapStringAny(currentOptions.ExtraOptions)
annotations := mustMakeMapStringString(currentOptions.Annotations)
@@ -225,3 +336,21 @@ func mainSingleIteration(logger model.Logger, experimentName string, currentOpti
// Otherwise just run OONI experiments as we normally do.
runx(ctx, sess, experimentName, annotations, extraOptions, currentOptions)
}
func documentationForOptions(name string, factory *registry.Factory) string {
var sb strings.Builder
options, err := factory.Options()
if err != nil || len(options) < 1 {
return ""
}
fmt.Fprint(&sb, "Pass KEY=VALUE options to the experiment. Available options:\n")
for name, info := range options {
if info.Doc == "" {
continue
}
fmt.Fprintf(&sb, "\n")
fmt.Fprintf(&sb, " -O, --option %s=<%s>\n", name, info.Type)
fmt.Fprintf(&sb, " %s\n", info.Doc)
}
return sb.String()
}
+1 -1
View File
@@ -6,7 +6,7 @@ func TestSimple(t *testing.T) {
if testing.Short() {
t.Skip("skip test in short mode")
}
MainWithConfiguration("example", Options{
MainWithConfiguration("example", &Options{
Yes: true,
})
}
+1 -1
View File
@@ -16,7 +16,7 @@ import (
// ooniRunMain runs the experiments described by the given OONI Run URLs. This
// function works with both v1 and v2 OONI Run URLs.
func ooniRunMain(ctx context.Context,
sess *engine.Session, currentOptions Options, annotations map[string]string) {
sess *engine.Session, currentOptions *Options, annotations map[string]string) {
runtimex.PanicIfTrue(
len(currentOptions.Inputs) <= 0,
"in oonirun mode you need to specify at least one URL using `-i URL`",
+1 -1
View File
@@ -13,7 +13,7 @@ import (
// runx runs the given experiment by name
func runx(ctx context.Context, sess oonirun.Session, experimentName string,
annotations map[string]string, extraOptions map[string]any, currentOptions Options) {
annotations map[string]string, extraOptions map[string]any, currentOptions *Options) {
desc := &oonirun.Experiment{
Annotations: annotations,
ExtraOptions: extraOptions,
+1 -1
View File
@@ -20,7 +20,7 @@ const (
)
// newSessionOrPanic creates and starts a new session or panics on failure
func newSessionOrPanic(ctx context.Context, currentOptions Options,
func newSessionOrPanic(ctx context.Context, currentOptions *Options,
miniooniDir string, logger model.Logger) *engine.Session {
var proxyURL *url.URL
if currentOptions.Proxy != "" {