diff --git a/.githooks/pre-commit b/.githooks/pre-commit index d933e462..4fd4e4f5 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,6 +1,10 @@ #!/bin/bash set -e; -git diff --cached --name-only | grep -q '.js$' && make lint-js; +git diff --cached --name-only | grep -q '.js$' && found=1 +if [ $found == 1 ]; then + make lint-js || exit 1 + npm run test --prefix client || exit 1 +fi found=0 git diff --cached --name-only | grep -q '.go$' && found=1 diff --git a/home/home.go b/home/home.go index b9d7a760..898fb4bd 100644 --- a/home/home.go +++ b/home/home.go @@ -126,7 +126,7 @@ func Main(version string, channel string, armVer string) { }() if args.serviceControlAction != "" { - handleServiceControlAction(args.serviceControlAction) + handleServiceControlAction(args) return } @@ -526,108 +526,25 @@ func cleanupAlways() { log.Info("Stopped") } -// command-line arguments -type options struct { - verbose bool // is verbose logging enabled - configFilename string // path to the config file - workDir string // path to the working directory where we will store the filters data and the querylog - bindHost string // host address to bind HTTP server on - bindPort int // port to serve HTTP pages on - logFile string // Path to the log file. If empty, write to stdout. If "syslog", writes to syslog - pidFile string // File name to save PID to - checkConfig bool // Check configuration and exit - disableUpdate bool // If set, don't check for updates - - // service control action (see service.ControlAction array + "status" command) - serviceControlAction string - - // runningAsService flag is set to true when options are passed from the service runner - runningAsService bool - - glinetMode bool // Activate GL-Inet mode +func exitWithError() { + os.Exit(64) } // loadOptions reads command line arguments and initializes configuration func loadOptions() options { - o := options{} + o, f, err := parse(os.Args[0], os.Args[1:]) - var printHelp func() - var opts = []struct { - longName string - shortName string - description string - callbackWithValue func(value string) - callbackNoValue func() - }{ - {"config", "c", "Path to the config file", func(value string) { o.configFilename = value }, nil}, - {"work-dir", "w", "Path to the working directory", func(value string) { o.workDir = value }, nil}, - {"host", "h", "Host address to bind HTTP server on", func(value string) { o.bindHost = value }, nil}, - {"port", "p", "Port to serve HTTP pages on", func(value string) { - v, err := strconv.Atoi(value) - if err != nil { - panic("Got port that is not a number") - } - o.bindPort = v - }, nil}, - {"service", "s", "Service control action: status, install, uninstall, start, stop, restart, reload (configuration)", func(value string) { - o.serviceControlAction = value - }, nil}, - {"logfile", "l", "Path to log file. If empty: write to stdout; if 'syslog': write to system log", func(value string) { - o.logFile = value - }, nil}, - {"pidfile", "", "Path to a file where PID is stored", func(value string) { o.pidFile = value }, nil}, - {"check-config", "", "Check configuration and exit", nil, func() { o.checkConfig = true }}, - {"no-check-update", "", "Don't check for updates", nil, func() { o.disableUpdate = true }}, - {"verbose", "v", "Enable verbose output", nil, func() { o.verbose = true }}, - {"glinet", "", "Run in GL-Inet compatibility mode", nil, func() { o.glinetMode = true }}, - {"version", "", "Show the version and exit", nil, func() { - fmt.Println(version()) + if err != nil { + log.Error(err.Error()) + _ = printHelp(os.Args[0]) + exitWithError() + } else if f != nil { + err = f() + if err != nil { + log.Error(err.Error()) + exitWithError() + } else { os.Exit(0) - }}, - {"help", "", "Print this help", nil, func() { - printHelp() - os.Exit(64) - }}, - } - printHelp = func() { - fmt.Printf("Usage:\n\n") - fmt.Printf("%s [options]\n\n", os.Args[0]) - fmt.Printf("Options:\n") - for _, opt := range opts { - val := "" - if opt.callbackWithValue != nil { - val = " VALUE" - } - if opt.shortName != "" { - fmt.Printf(" -%s, %-30s %s\n", opt.shortName, "--"+opt.longName+val, opt.description) - } else { - fmt.Printf(" %-34s %s\n", "--"+opt.longName+val, opt.description) - } - } - } - for i := 1; i < len(os.Args); i++ { - v := os.Args[i] - knownParam := false - for _, opt := range opts { - if v == "--"+opt.longName || (opt.shortName != "" && v == "-"+opt.shortName) { - if opt.callbackWithValue != nil { - if i+1 >= len(os.Args) { - log.Error("Got %s without argument\n", v) - os.Exit(64) - } - i++ - opt.callbackWithValue(os.Args[i]) - } else if opt.callbackNoValue != nil { - opt.callbackNoValue() - } - knownParam = true - break - } - } - if !knownParam { - log.Error("unknown option %v\n", v) - printHelp() - os.Exit(64) } } diff --git a/home/home_test.go b/home/home_test.go index 0868cb71..c78aee52 100644 --- a/home/home_test.go +++ b/home/home_test.go @@ -1,3 +1,5 @@ +// +build !race + package home import ( diff --git a/home/options.go b/home/options.go new file mode 100644 index 00000000..90d356e8 --- /dev/null +++ b/home/options.go @@ -0,0 +1,313 @@ +package home + +import ( + "fmt" + "os" + "strconv" +) + +// options passed from command-line arguments +type options struct { + verbose bool // is verbose logging enabled + configFilename string // path to the config file + workDir string // path to the working directory where we will store the filters data and the querylog + bindHost string // host address to bind HTTP server on + bindPort int // port to serve HTTP pages on + logFile string // Path to the log file. If empty, write to stdout. If "syslog", writes to syslog + pidFile string // File name to save PID to + checkConfig bool // Check configuration and exit + disableUpdate bool // If set, don't check for updates + + // service control action (see service.ControlAction array + "status" command) + serviceControlAction string + + // runningAsService flag is set to true when options are passed from the service runner + runningAsService bool + + glinetMode bool // Activate GL-Inet mode +} + +// functions used for their side-effects +type effect func() error + +type arg struct { + description string // a short, English description of the argument + longName string // the name of the argument used after '--' + shortName string // the name of the argument used after '-' + + // only one of updateWithValue, updateNoValue, and effect should be present + + updateWithValue func(o options, v string) (options, error) // the mutator for arguments with parameters + updateNoValue func(o options) (options, error) // the mutator for arguments without parameters + effect func(o options, exec string) (f effect, err error) // the side-effect closure generator + + serialize func(o options) []string // the re-serialization function back to arguments (return nil for omit) +} + +// {type}SliceOrNil functions check their parameter of type {type} +// against its zero value and return nil if the parameter value is +// zero otherwise they return a string slice of the parameter + +func stringSliceOrNil(s string) []string { + if s == "" { + return nil + } + return []string{s} +} + +func intSliceOrNil(i int) []string { + if i == 0 { + return nil + } + return []string{strconv.Itoa(i)} +} + +func boolSliceOrNil(b bool) []string { + if b { + return []string{} + } + return nil +} + +var args []arg + +var configArg = arg{ + "Path to the config file", + "config", "c", + func(o options, v string) (options, error) { o.configFilename = v; return o, nil }, + nil, + nil, + func(o options) []string { return stringSliceOrNil(o.configFilename) }, +} + +var workDirArg = arg{ + "Path to the working directory", + "work-dir", "w", + func(o options, v string) (options, error) { o.workDir = v; return o, nil }, nil, nil, + func(o options) []string { return stringSliceOrNil(o.workDir) }, +} + +var hostArg = arg{ + "Host address to bind HTTP server on", + "host", "h", + func(o options, v string) (options, error) { o.bindHost = v; return o, nil }, nil, nil, + func(o options) []string { return stringSliceOrNil(o.bindHost) }, +} + +var portArg = arg{ + "Port to serve HTTP pages on", + "port", "p", + func(o options, v string) (options, error) { + var err error + var p int + minPort, maxPort := 0, 1<<16-1 + if p, err = strconv.Atoi(v); err != nil { + err = fmt.Errorf("port '%s' is not a number", v) + } else if p < minPort || p > maxPort { + err = fmt.Errorf("port %d not in range %d - %d", p, minPort, maxPort) + } else { + o.bindPort = p + } + return o, err + }, nil, nil, + func(o options) []string { return intSliceOrNil(o.bindPort) }, +} + +var serviceArg = arg{ + "Service control action: status, install, uninstall, start, stop, restart, reload (configuration)", + "service", "s", + func(o options, v string) (options, error) { + o.serviceControlAction = v + return o, nil + }, nil, nil, + func(o options) []string { return stringSliceOrNil(o.serviceControlAction) }, +} + +var logfileArg = arg{ + "Path to log file. If empty: write to stdout; if 'syslog': write to system log", + "logfile", "l", + func(o options, v string) (options, error) { o.logFile = v; return o, nil }, nil, nil, + func(o options) []string { return stringSliceOrNil(o.logFile) }, +} + +var pidfileArg = arg{ + "Path to a file where PID is stored", + "pidfile", "", + func(o options, v string) (options, error) { o.pidFile = v; return o, nil }, nil, nil, + func(o options) []string { return stringSliceOrNil(o.pidFile) }, +} + +var checkConfigArg = arg{ + "Check configuration and exit", + "check-config", "", + nil, func(o options) (options, error) { o.checkConfig = true; return o, nil }, nil, + func(o options) []string { return boolSliceOrNil(o.checkConfig) }, +} + +var noCheckUpdateArg = arg{ + "Don't check for updates", + "no-check-update", "", + nil, func(o options) (options, error) { o.disableUpdate = true; return o, nil }, nil, + func(o options) []string { return boolSliceOrNil(o.disableUpdate) }, +} + +var verboseArg = arg{ + "Enable verbose output", + "verbose", "v", + nil, func(o options) (options, error) { o.verbose = true; return o, nil }, nil, + func(o options) []string { return boolSliceOrNil(o.verbose) }, +} + +var glinetArg = arg{ + "Run in GL-Inet compatibility mode", + "glinet", "", + nil, func(o options) (options, error) { o.glinetMode = true; return o, nil }, nil, + func(o options) []string { return boolSliceOrNil(o.glinetMode) }, +} + +var versionArg = arg{ + "Show the version and exit", + "version", "", + nil, nil, func(o options, exec string) (effect, error) { + return func() error { fmt.Println(version()); os.Exit(0); return nil }, nil + }, + func(o options) []string { return nil }, +} + +var helpArg = arg{ + "Print this help", + "help", "", + nil, nil, func(o options, exec string) (effect, error) { + return func() error { _ = printHelp(exec); os.Exit(64); return nil }, nil + }, + func(o options) []string { return nil }, +} + +func init() { + args = []arg{ + configArg, + workDirArg, + hostArg, + portArg, + serviceArg, + logfileArg, + pidfileArg, + checkConfigArg, + noCheckUpdateArg, + verboseArg, + glinetArg, + versionArg, + helpArg, + } +} + +func getUsageLines(exec string, args []arg) []string { + usage := []string{ + "Usage:", + "", + fmt.Sprintf("%s [options]", exec), + "", + "Options:", + } + for _, arg := range args { + val := "" + if arg.updateWithValue != nil { + val = " VALUE" + } + if arg.shortName != "" { + usage = append(usage, fmt.Sprintf(" -%s, %-30s %s", + arg.shortName, + "--"+arg.longName+val, + arg.description)) + } else { + usage = append(usage, fmt.Sprintf(" %-34s %s", + "--"+arg.longName+val, + arg.description)) + } + } + return usage +} + +func printHelp(exec string) error { + for _, line := range getUsageLines(exec, args) { + _, err := fmt.Println(line) + if err != nil { + return err + } + } + return nil +} + +func argMatches(a arg, v string) bool { + return v == "--"+a.longName || (a.shortName != "" && v == "-"+a.shortName) +} + +func parse(exec string, ss []string) (o options, f effect, err error) { + for i := 0; i < len(ss); i++ { + v := ss[i] + knownParam := false + for _, arg := range args { + if argMatches(arg, v) { + if arg.updateWithValue != nil { + if i+1 >= len(ss) { + return o, f, fmt.Errorf("got %s without argument", v) + } + i++ + o, err = arg.updateWithValue(o, ss[i]) + if err != nil { + return + } + } else if arg.updateNoValue != nil { + o, err = arg.updateNoValue(o) + if err != nil { + return + } + } else if arg.effect != nil { + var eff effect + eff, err = arg.effect(o, exec) + if err != nil { + return + } + if eff != nil { + prevf := f + f = func() error { + var err error + if prevf != nil { + err = prevf() + } + if err == nil { + err = eff() + } + return err + } + } + } + knownParam = true + break + } + } + if !knownParam { + return o, f, fmt.Errorf("unknown option %v", v) + } + } + + return +} + +func shortestFlag(a arg) string { + if a.shortName != "" { + return "-" + a.shortName + } + return "--" + a.longName +} + +func serialize(o options) []string { + ss := []string{} + for _, arg := range args { + s := arg.serialize(o) + if s != nil { + ss = append(ss, append([]string{shortestFlag(arg)}, s...)...) + } + } + return ss +} diff --git a/home/options_test.go b/home/options_test.go new file mode 100644 index 00000000..5a2b1155 --- /dev/null +++ b/home/options_test.go @@ -0,0 +1,237 @@ +package home + +import ( + "fmt" + "testing" +) + +func testParseOk(t *testing.T, ss ...string) options { + o, _, err := parse("", ss) + if err != nil { + t.Fatal(err.Error()) + } + return o +} + +func testParseErr(t *testing.T, descr string, ss ...string) { + _, _, err := parse("", ss) + if err == nil { + t.Fatalf("expected an error because %s but no error returned", descr) + } +} + +func testParseParamMissing(t *testing.T, param string) { + testParseErr(t, fmt.Sprintf("%s parameter missing", param), param) +} + +func TestParseVerbose(t *testing.T) { + if testParseOk(t).verbose { + t.Fatal("empty is not verbose") + } + if !testParseOk(t, "-v").verbose { + t.Fatal("-v is verbose") + } + if !testParseOk(t, "--verbose").verbose { + t.Fatal("--verbose is verbose") + } +} + +func TestParseConfigFilename(t *testing.T) { + if testParseOk(t).configFilename != "" { + t.Fatal("empty is no config filename") + } + if testParseOk(t, "-c", "path").configFilename != "path" { + t.Fatal("-c is config filename") + } + testParseParamMissing(t, "-c") + if testParseOk(t, "--config", "path").configFilename != "path" { + t.Fatal("--configFilename is config filename") + } + testParseParamMissing(t, "--config") +} + +func TestParseWorkDir(t *testing.T) { + if testParseOk(t).workDir != "" { + t.Fatal("empty is no work dir") + } + if testParseOk(t, "-w", "path").workDir != "path" { + t.Fatal("-w is work dir") + } + testParseParamMissing(t, "-w") + if testParseOk(t, "--work-dir", "path").workDir != "path" { + t.Fatal("--work-dir is work dir") + } + testParseParamMissing(t, "--work-dir") +} + +func TestParseBindHost(t *testing.T) { + if testParseOk(t).bindHost != "" { + t.Fatal("empty is no host") + } + if testParseOk(t, "-h", "addr").bindHost != "addr" { + t.Fatal("-h is host") + } + testParseParamMissing(t, "-h") + if testParseOk(t, "--host", "addr").bindHost != "addr" { + t.Fatal("--host is host") + } + testParseParamMissing(t, "--host") +} + +func TestParseBindPort(t *testing.T) { + if testParseOk(t).bindPort != 0 { + t.Fatal("empty is port 0") + } + if testParseOk(t, "-p", "65535").bindPort != 65535 { + t.Fatal("-p is port") + } + testParseParamMissing(t, "-p") + if testParseOk(t, "--port", "65535").bindPort != 65535 { + t.Fatal("--port is port") + } + testParseParamMissing(t, "--port") +} + +func TestParseBindPortBad(t *testing.T) { + testParseErr(t, "not an int", "-p", "x") + testParseErr(t, "hex not supported", "-p", "0x100") + testParseErr(t, "port negative", "-p", "-1") + testParseErr(t, "port too high", "-p", "65536") + testParseErr(t, "port too high", "-p", "4294967297") // 2^32 + 1 + testParseErr(t, "port too high", "-p", "18446744073709551617") // 2^64 + 1 +} + +func TestParseLogfile(t *testing.T) { + if testParseOk(t).logFile != "" { + t.Fatal("empty is no log file") + } + if testParseOk(t, "-l", "path").logFile != "path" { + t.Fatal("-l is log file") + } + if testParseOk(t, "--logfile", "path").logFile != "path" { + t.Fatal("--logfile is log file") + } +} + +func TestParsePidfile(t *testing.T) { + if testParseOk(t).pidFile != "" { + t.Fatal("empty is no pid file") + } + if testParseOk(t, "--pidfile", "path").pidFile != "path" { + t.Fatal("--pidfile is pid file") + } +} + +func TestParseCheckConfig(t *testing.T) { + if testParseOk(t).checkConfig { + t.Fatal("empty is not check config") + } + if !testParseOk(t, "--check-config").checkConfig { + t.Fatal("--check-config is check config") + } +} + +func TestParseDisableUpdate(t *testing.T) { + if testParseOk(t).disableUpdate { + t.Fatal("empty is not disable update") + } + if !testParseOk(t, "--no-check-update").disableUpdate { + t.Fatal("--no-check-update is disable update") + } +} + +func TestParseService(t *testing.T) { + if testParseOk(t).serviceControlAction != "" { + t.Fatal("empty is no service command") + } + if testParseOk(t, "-s", "command").serviceControlAction != "command" { + t.Fatal("-s is service command") + } + if testParseOk(t, "--service", "command").serviceControlAction != "command" { + t.Fatal("--service is service command") + } +} + +func TestParseGLInet(t *testing.T) { + if testParseOk(t).glinetMode { + t.Fatal("empty is not GL-Inet mode") + } + if !testParseOk(t, "--glinet").glinetMode { + t.Fatal("--glinet is GL-Inet mode") + } +} + +func TestParseUnknown(t *testing.T) { + testParseErr(t, "unknown word", "x") + testParseErr(t, "unknown short", "-x") + testParseErr(t, "unknown long", "--x") + testParseErr(t, "unknown triple", "---x") + testParseErr(t, "unknown plus", "+x") + testParseErr(t, "unknown dash", "-") +} + +func testSerialize(t *testing.T, o options, ss ...string) { + result := serialize(o) + if len(result) != len(ss) { + t.Fatalf("expected %s but got %s", ss, result) + } + for i, r := range result { + if r != ss[i] { + t.Fatalf("expected %s but got %s", ss, result) + } + } +} + +func TestSerializeEmpty(t *testing.T) { + testSerialize(t, options{}) +} + +func TestSerializeConfigFilename(t *testing.T) { + testSerialize(t, options{configFilename: "path"}, "-c", "path") +} + +func TestSerializeWorkDir(t *testing.T) { + testSerialize(t, options{workDir: "path"}, "-w", "path") +} + +func TestSerializeBindHost(t *testing.T) { + testSerialize(t, options{bindHost: "addr"}, "-h", "addr") +} + +func TestSerializeBindPort(t *testing.T) { + testSerialize(t, options{bindPort: 666}, "-p", "666") +} + +func TestSerializeLogfile(t *testing.T) { + testSerialize(t, options{logFile: "path"}, "-l", "path") +} + +func TestSerializePidfile(t *testing.T) { + testSerialize(t, options{pidFile: "path"}, "--pidfile", "path") +} + +func TestSerializeCheckConfig(t *testing.T) { + testSerialize(t, options{checkConfig: true}, "--check-config") +} + +func TestSerializeDisableUpdate(t *testing.T) { + testSerialize(t, options{disableUpdate: true}, "--no-check-update") +} + +func TestSerializeService(t *testing.T) { + testSerialize(t, options{serviceControlAction: "run"}, "-s", "run") +} + +func TestSerializeGLInet(t *testing.T) { + testSerialize(t, options{glinetMode: true}, "--glinet") +} + +func TestSerializeMultiple(t *testing.T) { + testSerialize(t, options{ + serviceControlAction: "run", + configFilename: "config", + workDir: "work", + pidFile: "pid", + disableUpdate: true, + }, "-c", "config", "-w", "work", "-s", "run", "--pidfile", "pid", "--no-check-update") +} diff --git a/home/service.go b/home/service.go index 21c9cfc6..b48c743b 100644 --- a/home/service.go +++ b/home/service.go @@ -24,12 +24,14 @@ const ( // Represents the program that will be launched by a service or daemon type program struct { + opts options } // Start should quickly start the program func (p *program) Start(s service.Service) error { // Start should not block. Do the actual work async. - args := options{runningAsService: true} + args := p.opts + args.runningAsService = true go run(args) return nil } @@ -125,7 +127,8 @@ func sendSigReload() { // run - this is a special command that is not supposed to be used directly // it is specified when we register a service, and it indicates to the app // that it is being run as a service/daemon. -func handleServiceControlAction(action string) { +func handleServiceControlAction(opts options) { + action := opts.serviceControlAction log.Printf("Service control action: %s", action) if action == "reload" { @@ -137,15 +140,17 @@ func handleServiceControlAction(action string) { if err != nil { log.Fatal("Unable to find the path to the current directory") } + runOpts := opts + runOpts.serviceControlAction = "run" svcConfig := &service.Config{ Name: serviceName, DisplayName: serviceDisplayName, Description: serviceDescription, WorkingDirectory: pwd, - Arguments: []string{"-s", "run"}, + Arguments: serialize(runOpts), } configureService(svcConfig) - prg := &program{} + prg := &program{runOpts} s, err := service.New(prg, svcConfig) if err != nil { log.Fatal(err)