354 lines
11 KiB
Go
354 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"cmp"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"unicode"
|
|
|
|
shipped "github.com/lixenwraith/logwisp/config"
|
|
"github.com/lixenwraith/logwisp/internal/config"
|
|
|
|
"github.com/lixenwraith/toml"
|
|
)
|
|
|
|
// command is a group of subcommands, recognized only as lw's first argument
|
|
type command struct {
|
|
name, summary, footer string
|
|
subcommands []subcommand
|
|
}
|
|
|
|
// subcommand is one `lw COMMAND NAME`: define registers its flags and
|
|
// returns the work, which runs once they are parsed and checked.
|
|
type subcommand struct {
|
|
name, synopsis, summary string
|
|
required []string
|
|
define func(fs *flag.FlagSet) func(stdout, stderr io.Writer) error
|
|
}
|
|
|
|
var commands = []command{
|
|
{"auth", "SCRAM users, bearer tokens, tcp sink viewing",
|
|
"Credential changes apply on SIGHUP.", authCommands},
|
|
{"tls", "A CA and certificates for TLS and mTLS",
|
|
"Files are never overwritten; keys are written mode 0600.", tlsCommands},
|
|
{"preset", "Show the pipeline a preset makes, as TOML",
|
|
"lw --preset NAME,key=value runs one; flags here are its keys.", presetCommands()},
|
|
{"config", "Place the annotated default configuration",
|
|
"Files are never overwritten; lw --dump prints the effective configuration.", configCommands},
|
|
}
|
|
|
|
// shorts are lw's one-letter flags, each its long flag in every command that
|
|
// defines that flag; lw's own command line takes the top ones
|
|
var shorts = map[string]struct {
|
|
long string
|
|
top bool
|
|
}{
|
|
"c": {"config", true}, "h": {"help", true}, "p": {"preset", true},
|
|
"q": {"quiet", true}, "t": {"check", true}, "T": {"tui", true}, "V": {"version", true},
|
|
"u": {"user", false},
|
|
}
|
|
|
|
// switches are lw's own options that take no value and are no setting, so no
|
|
// file or environment variable turns one on
|
|
var switches = []string{"check", "dump", "version", "schema", "tui"}
|
|
|
|
// usageError is a command-line mistake: exit status 2 rather than 1
|
|
type usageError string
|
|
|
|
func (e usageError) Error() string { return string(e) }
|
|
|
|
// run runs a subcommand and returns the exit status: 0 success or help,
|
|
// 1 failure, 2 usage error.
|
|
func (c *command) run(args []string, stdout, stderr io.Writer) int {
|
|
if len(args) == 0 || slices.Contains([]string{"-h", "-help", "--help", "help"}, args[0]) {
|
|
c.usage(stderr)
|
|
if len(args) == 0 {
|
|
return 2
|
|
}
|
|
return 0
|
|
}
|
|
i := slices.IndexFunc(c.subcommands, func(s subcommand) bool { return s.name == args[0] })
|
|
if i < 0 {
|
|
fmt.Fprintf(stderr, "lw %s: unknown command %q\n\n", c.name, args[0])
|
|
c.usage(stderr)
|
|
return 2
|
|
}
|
|
s := c.subcommands[i]
|
|
fs := flag.NewFlagSet("lw "+c.name+" "+s.name, flag.ContinueOnError)
|
|
// flag names a long flag with one dash: its errors, and the usage, print below
|
|
fs.SetOutput(io.Discard)
|
|
fs.Usage = func() {}
|
|
usage := func() {
|
|
fmt.Fprintf(stderr, "Usage: %s %s\n\n%s.\n\n", fs.Name(), s.synopsis, s.summary)
|
|
printFlags(stderr, fs)
|
|
}
|
|
work := s.define(fs)
|
|
for letter, short := range shorts {
|
|
if f := fs.Lookup(short.long); f != nil {
|
|
fs.Var(f.Value, letter, "") // no usage: printFlags shows it with its long flag
|
|
}
|
|
}
|
|
err := fs.Parse(args[1:])
|
|
switch {
|
|
case errors.Is(err, flag.ErrHelp):
|
|
usage()
|
|
return 0
|
|
case err != nil:
|
|
err = usageError(longFlag.ReplaceAllString(err.Error(), "$1--$2"))
|
|
default:
|
|
err = checkArgs(fs, s.required)
|
|
}
|
|
if err == nil {
|
|
err = work(stdout, stderr)
|
|
}
|
|
if err == nil {
|
|
return 0
|
|
}
|
|
fmt.Fprintf(stderr, "%s: %v\n", fs.Name(), err)
|
|
if _, ok := errors.AsType[usageError](err); ok {
|
|
usage()
|
|
return 2
|
|
}
|
|
return 1
|
|
}
|
|
|
|
// longFlag is a flag name of two or more letters in flag's errors
|
|
var longFlag = regexp.MustCompile(`(^|\s)-(\w[\w-]+)`)
|
|
|
|
func (c *command) usage(w io.Writer) {
|
|
fmt.Fprintf(w, "Usage: lw %s COMMAND [flags]\n\n", c.name)
|
|
for _, s := range c.subcommands {
|
|
fmt.Fprintf(w, " %-12s %s\n", s.name, s.summary)
|
|
}
|
|
fmt.Fprintf(w, "\nRun lw %s COMMAND -h for its flags. %s\n"+
|
|
"Exit status: 0 success, 1 failure, 2 usage error.\n", c.name, c.footer)
|
|
}
|
|
|
|
// printFlags lists fs's flags as " -u, --user NAME" or " --credentials FILE"
|
|
func printFlags(w io.Writer, fs *flag.FlagSet) {
|
|
fs.VisitAll(func(f *flag.Flag) {
|
|
if f.Usage == "" {
|
|
return
|
|
}
|
|
head := " "
|
|
for letter, short := range shorts {
|
|
if short.long == f.Name {
|
|
head = "-" + letter + ", "
|
|
}
|
|
}
|
|
arg, usage := flag.UnquoteUsage(f)
|
|
// Only a bool flag has no argument; its "false" default goes unsaid
|
|
if f.DefValue != "" && (arg != "" || f.DefValue != "false") {
|
|
usage += " (default: " + f.DefValue + ")"
|
|
}
|
|
if arg == "string" {
|
|
arg = "value"
|
|
}
|
|
if arg != "" {
|
|
arg = " " + strings.ToUpper(arg)
|
|
}
|
|
fmt.Fprintf(w, " %s--%s%s\n %s\n", head, f.Name, arg, usage)
|
|
})
|
|
}
|
|
|
|
func checkArgs(fs *flag.FlagSet, required []string) error {
|
|
if fs.NArg() > 0 {
|
|
return usageError(fmt.Sprintf("unexpected argument %q", fs.Arg(0)))
|
|
}
|
|
for _, name := range required {
|
|
if fs.Lookup(name).Value.String() == "" {
|
|
return usageError("--" + name + " is required")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// invocation is a parsed command line: a command with its arguments, a help
|
|
// request, or the switches given and what config.Load reads.
|
|
type invocation struct {
|
|
command *command
|
|
args []string
|
|
help bool
|
|
on map[string]bool
|
|
load config.Args
|
|
}
|
|
|
|
// parseCommandLine reads lw's own flags, a short one as its long one; any
|
|
// other --flag is a setting, passed to lixenwraith/config as --path=value or a
|
|
// bare switch so it never guesses. A flag's value is the next argument unless
|
|
// that starts with '-'. Every error is a usage error; a help request wins.
|
|
func parseCommandLine(argv []string) (inv invocation, err error) {
|
|
defer func() {
|
|
if inv.help {
|
|
err = nil
|
|
}
|
|
}()
|
|
if len(argv) > 0 {
|
|
if i := slices.IndexFunc(commands, func(c command) bool { return c.name == argv[0] }); i >= 0 {
|
|
return invocation{command: &commands[i], args: argv[1:]}, nil
|
|
}
|
|
}
|
|
specFlags, settings := config.SpecFlags(), config.Settings()
|
|
unexpected := func(word string) error {
|
|
return fmt.Errorf("unexpected argument %q: lw takes options only (a configuration file is -c FILE, a switch's value follows '=')", word)
|
|
}
|
|
next := func(i *int) string {
|
|
if *i+1 < len(argv) && !strings.HasPrefix(argv[*i+1], "-") {
|
|
*i++
|
|
return argv[*i]
|
|
}
|
|
return ""
|
|
}
|
|
for i := 0; i < len(argv); i++ {
|
|
arg := argv[i]
|
|
name, value, inline := strings.Cut(arg, "=")
|
|
typed := name
|
|
if short, ok := shorts[strings.TrimPrefix(name, "-")]; ok && short.top && len(name) == 2 {
|
|
arg, name = "--"+short.long+arg[len(name):], "--"+short.long
|
|
}
|
|
switch {
|
|
case arg == "--":
|
|
if i+1 < len(argv) {
|
|
err = cmp.Or(err, unexpected(argv[i+1]))
|
|
}
|
|
return inv, err
|
|
case arg == "--help" || i == 0 && arg == "help":
|
|
inv.help = true
|
|
case name == "--config":
|
|
if !inline {
|
|
value = next(&i)
|
|
}
|
|
if value == "" {
|
|
err = cmp.Or(err, fmt.Errorf("%s requires a configuration file path", typed))
|
|
}
|
|
inv.load.File = value
|
|
case strings.HasPrefix(name, "--") && slices.Contains(switches, name[2:]):
|
|
if inline {
|
|
err = cmp.Or(err, fmt.Errorf("%s takes no value", typed))
|
|
}
|
|
if inv.on == nil {
|
|
inv.on = map[string]bool{}
|
|
}
|
|
inv.on[name[2:]] = true
|
|
case arg == "--color":
|
|
// Bare, it means always: config takes no bare flag for a string key
|
|
inv.load.Overrides = append(inv.load.Overrides, "--color="+cmp.Or(next(&i), "always"))
|
|
case strings.HasPrefix(name, "--") && slices.ContainsFunc(specFlags, isFlag(name[2:])):
|
|
if !inline {
|
|
value = next(&i)
|
|
}
|
|
if value == "" {
|
|
err = cmp.Or(err, fmt.Errorf("%s requires a value; one that starts with '-' follows '='", typed))
|
|
}
|
|
inv.load.Specs = append(inv.load.Specs, config.Spec{Flag: name[2:], Value: value})
|
|
case len(name) > 1 && name[0] == '-' && unicode.IsLetter(rune(name[1])):
|
|
// lw's only single-dash options are the shorts
|
|
err = cmp.Or(err, fmt.Errorf("unknown option %s: long options take two dashes, and a value that starts with '-' follows '=' (lw --help lists them)", typed))
|
|
case !strings.HasPrefix(name, "--"):
|
|
err = cmp.Or(err, unexpected(arg))
|
|
default:
|
|
isSwitch, known := settings[name[2:]]
|
|
if !known {
|
|
err = cmp.Or(err, fmt.Errorf("unknown option %s (lw --help lists them)", typed))
|
|
}
|
|
if !inline && !isSwitch {
|
|
if value = next(&i); value == "" && known {
|
|
err = cmp.Or(err, fmt.Errorf("%s needs a value; one that starts with '-' follows '='", typed))
|
|
}
|
|
arg += "=" + value
|
|
}
|
|
inv.load.Overrides = append(inv.load.Overrides, arg)
|
|
}
|
|
}
|
|
return inv, err
|
|
}
|
|
|
|
func isFlag(name string) func(config.SpecFlag) bool {
|
|
return func(f config.SpecFlag) bool { return f.Flag == name }
|
|
}
|
|
|
|
// presetCommands makes each preset a subcommand whose flags are its keys
|
|
func presetCommands() []subcommand {
|
|
var subs []subcommand
|
|
for _, p := range config.Presets() {
|
|
s := subcommand{name: p.Name, summary: p.Summary}
|
|
var synopsis []string
|
|
for _, k := range p.Params {
|
|
if k.Required {
|
|
s.required = append(s.required, k.Name)
|
|
synopsis = append(synopsis, "--"+k.Name+" VALUE")
|
|
}
|
|
}
|
|
s.synopsis = strings.Join(append(synopsis, "[--KEY VALUE...]"), " ")
|
|
s.define = func(fs *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
|
values := map[string]*string{}
|
|
for _, k := range p.Params {
|
|
values[k.Name] = fs.String(k.Name, k.Default, k.Help)
|
|
}
|
|
return func(stdout, _ io.Writer) error {
|
|
set := map[string]string{}
|
|
for name, v := range values {
|
|
set[name] = *v
|
|
}
|
|
pipeline, err := config.ExpandPreset(p.Name, set, config.HostIsDir)
|
|
if err != nil {
|
|
return usageError(err.Error())
|
|
}
|
|
data, err := toml.Marshal(map[string]any{"pipelines": []config.PipelineConfig{pipeline}})
|
|
if err == nil {
|
|
_, err = stdout.Write(data)
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
subs = append(subs, s)
|
|
}
|
|
return subs
|
|
}
|
|
|
|
var configCommands = []subcommand{
|
|
{"init", "[--out FILE]",
|
|
"Write the annotated default configuration, to edit and run",
|
|
nil, defineConfigInit},
|
|
}
|
|
|
|
func defineConfigInit(flags *flag.FlagSet) func(stdout, stderr io.Writer) error {
|
|
out := flags.String("out", "", "`file` to write (default: ~/.config/logwisp/logwisp.toml)")
|
|
return func(_, stderr io.Writer) error {
|
|
user, err := config.UserFile()
|
|
path := cmp.Or(*out, user)
|
|
if path == "" {
|
|
return fmt.Errorf("no home directory for the default file (%v): name one with --out", err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := writeNew(path, shipped.Sample, 0o644); errors.Is(err, fs.ErrExist) {
|
|
if fi, err := os.Stat(path); err == nil && fi.IsDir() {
|
|
return fmt.Errorf("%s is a directory: name the file, as in %s", path, filepath.Join(path, "logwisp.toml"))
|
|
}
|
|
return fmt.Errorf("%s exists; remove it to replace it", path)
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
// -c refuses a path that climbs out with ..; an absolute one loads anywhere
|
|
if abs, err := filepath.Abs(path); err == nil {
|
|
path = abs
|
|
}
|
|
if found, err := filepath.Abs(config.DefaultPath()); err == nil && found == path {
|
|
fmt.Fprintf(stderr, "wrote %s; lw reads it without -c\n", path)
|
|
} else {
|
|
fmt.Fprintf(stderr, "wrote %s; run it with lw -c %s\n", path, path)
|
|
}
|
|
return nil
|
|
}
|
|
}
|