diff --git a/benchmark/cmd/command/banner.go b/benchmark/cmd/command/banner.go new file mode 100644 index 0000000000..142a997875 --- /dev/null +++ b/benchmark/cmd/command/banner.go @@ -0,0 +1,26 @@ +package command + +import ( + "github.com/pyroscope-io/pyroscope/pkg/cli" +) + +// made here http://patorjk.com/software/taag/#p=display&f=Doom&t=Pyrobench +var banner = ` +______ _ _ +| ___ \ | | | | +| |_/ / _ _ __ ___ | |__ ___ _ __ ___| |__ +| __/ | | | '__/ _ \| '_ \ / _ \ '_ \ / __| '_ \ +| | | |_| | | | (_) | |_) | __/ | | | (__| | | | +\_| \__, |_| \___/|_.__/ \___|_| |_|\___|_| |_| + __/ | + |___/ +` + +func init() { + // removes extra new lines + banner = banner[1 : len(banner)-2] +} + +func gradientBanner() string { + return cli.GradientBanner(banner) +} diff --git a/benchmark/cmd/command/command.go b/benchmark/cmd/command/command.go new file mode 100644 index 0000000000..7c30a4bba3 --- /dev/null +++ b/benchmark/cmd/command/command.go @@ -0,0 +1,10 @@ +package command + +import ( + "github.com/pyroscope-io/pyroscope/pkg/cli" + "github.com/spf13/viper" +) + +func newViper() *viper.Viper { + return cli.NewViper("PYROBENCH") +} diff --git a/benchmark/cmd/command/loadgen.go b/benchmark/cmd/command/loadgen.go new file mode 100644 index 0000000000..4c0ce0d29d --- /dev/null +++ b/benchmark/cmd/command/loadgen.go @@ -0,0 +1,22 @@ +package command + +import ( + "github.com/pyroscope-io/pyroscope/benchmark/config" + "github.com/pyroscope-io/pyroscope/benchmark/loadgen" + "github.com/pyroscope-io/pyroscope/pkg/cli" + "github.com/spf13/cobra" +) + +func newLoadGen(cfg *config.LoadGen) *cobra.Command { + vpr := newViper() + loadgenCmd := &cobra.Command{ + Use: "loadgen [flags]", + Short: "Generates load", + RunE: cli.CreateCmdRunFn(cfg, vpr, func(_ *cobra.Command, args []string) error { + return loadgen.Cli(cfg) + }), + } + + cli.PopulateFlagSet(cfg, loadgenCmd.Flags(), vpr) + return loadgenCmd +} diff --git a/benchmark/cmd/command/root.go b/benchmark/cmd/command/root.go new file mode 100644 index 0000000000..d1ea584b3b --- /dev/null +++ b/benchmark/cmd/command/root.go @@ -0,0 +1,66 @@ +package command + +import ( + "fmt" + "os" + "runtime" + "strings" + + "github.com/pyroscope-io/pyroscope/benchmark/config" + "github.com/pyroscope-io/pyroscope/pkg/cli" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +func newRootCmd(cfg *config.LoadGen) *cobra.Command { + rootCmd := &cobra.Command{ + Use: "pyrobench [flags] ", + } + + rootCmd.SetUsageFunc(func(cmd *cobra.Command) error { + fmt.Println(gradientBanner()) + fmt.Println(cli.DefaultUsageFunc(cmd.Flags(), cmd)) + return nil + }) + + rootCmd.SetHelpFunc(func(cmd *cobra.Command, a []string) { + fmt.Println(gradientBanner()) + fmt.Println(cli.DefaultUsageFunc(cmd.Flags(), cmd)) + }) + + return rootCmd +} + +// Initialize adds all child commands to the root command and sets flags appropriately +func Initialize() error { + var cfg config.Config + + rootCmd := newRootCmd(&cfg.LoadGen) + rootCmd.SilenceErrors = true + rootCmd.AddCommand( + newLoadGen(&cfg.LoadGen), + ) + + logrus.SetReportCaller(true) + logrus.SetFormatter(&logrus.TextFormatter{ + TimestampFormat: "2006-01-02T15:04:05.000000", + FullTimestamp: true, + CallerPrettyfier: func(f *runtime.Frame) (string, string) { + filename := f.File + if len(filename) > 38 { + filename = filename[38:] + } + return "", fmt.Sprintf(" %s:%d", filename, f.Line) + }, + }) + + args := os.Args[1:] + for i, arg := range args { + if len(arg) > 2 && strings.HasPrefix(arg, "-") && !strings.HasPrefix(arg, "--") { + args[i] = fmt.Sprintf("-%s", arg) + } + } + + rootCmd.SetArgs(args) + return rootCmd.Execute() +} diff --git a/benchmark/cmd/logging.go b/benchmark/cmd/logging.go new file mode 100644 index 0000000000..ed9fc91bc5 --- /dev/null +++ b/benchmark/cmd/logging.go @@ -0,0 +1,9 @@ +package main + +import ( + "github.com/pyroscope-io/pyroscope/pkg/cli" +) + +func init() { + cli.InitLogging() +} diff --git a/benchmark/cmd/main.go b/benchmark/cmd/main.go new file mode 100644 index 0000000000..281a89d98f --- /dev/null +++ b/benchmark/cmd/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "fmt" + "os" + + "github.com/fatih/color" + "github.com/pyroscope-io/pyroscope/benchmark/cmd/command" +) + +func main() { + if err := command.Initialize(); err != nil { + fatalf("%s %v\n\n", color.RedString("Error:"), err) + } +} + +func fatalf(format string, args ...interface{}) { + _, _ = fmt.Fprintf(os.Stderr, format, args...) + os.Exit(1) +} diff --git a/benchmark/config/config.go b/benchmark/config/config.go new file mode 100644 index 0000000000..b8de249551 --- /dev/null +++ b/benchmark/config/config.go @@ -0,0 +1,24 @@ +package config + +type Config struct { + LoadGen LoadGen `skip:"true" mapstructure:",squash"` +} + +type LoadGen struct { + LogLevel string `def:"info" desc:"log level: debug|info|warn|error" mapstructure:"log-level"` + + ServerAddress string `def:"http://localhost:4040" desc:"address of the pyroscope instance being attacked" mapstructure:"server-address"` + RandSeed int `def:"23061912" desc:""` + ProfileWidth int `def:"20"` + ProfileDepth int `def:"20"` + ProfileSymbolLength int `def:"30"` + Fixtures int `def:"30" desc:"how many different profiles to generate per app"` + Apps int `def:"20" desc:"how many pyroscope apps to emulate"` + Clients int `def:"20" desc:"how many pyroscope clients to emulate"` + Requests int `def:"10000" desc:"how many requests each clients should make"` + + WaitUntilAvailable bool `def:"true" desc:"wait until endpoint is available"` +} + +// File can be read from file system. +type File interface{ Path() string } diff --git a/benchmark/config/interface.go b/benchmark/config/interface.go new file mode 100644 index 0000000000..13e2e5ea08 --- /dev/null +++ b/benchmark/config/interface.go @@ -0,0 +1,18 @@ +package config + +import ( + "github.com/sirupsen/logrus" +) + +type FileConfiger interface{ ConfigFilePath() string } + +type LoggerFunc func(s string) +type LoggerConfiger interface{ InitializeLogging() LoggerFunc } + +func (cfg LoadGen) InitializeLogging() LoggerFunc { + if l, err := logrus.ParseLevel(cfg.LogLevel); err == nil { + logrus.SetLevel(l) + } + + return nil +} diff --git a/benchmark/loadgen/loadgen.go b/benchmark/loadgen/loadgen.go new file mode 100644 index 0000000000..4e07923469 --- /dev/null +++ b/benchmark/loadgen/loadgen.go @@ -0,0 +1,170 @@ +package loadgen + +import ( + "encoding/hex" + "fmt" + "math/rand" + "net/http" + "sync" + "time" + + "github.com/pyroscope-io/pyroscope/benchmark/config" + "github.com/pyroscope-io/pyroscope/pkg/agent/upstream" + "github.com/pyroscope-io/pyroscope/pkg/agent/upstream/remote" + "github.com/pyroscope-io/pyroscope/pkg/structs/transporttrie" + "github.com/sirupsen/logrus" +) + +// how many retries to check the pyroscope server is up +const MaxReadinessRetries = 10 + +type Fixtures [][]*transporttrie.Trie + +type LoadGen struct { + Config *config.LoadGen + Rand *rand.Rand + SymbolBuf []byte +} + +func Cli(cfg *config.LoadGen) error { + r := rand.New(rand.NewSource(int64(cfg.RandSeed))) + l := &LoadGen{ + Config: cfg, + Rand: r, + SymbolBuf: make([]byte, cfg.ProfileSymbolLength), + } + + return l.Run(cfg) +} + +func (l *LoadGen) Run(cfg *config.LoadGen) error { + logrus.Info("checking server is available...") + err := waitUntilEndpointReady(cfg.ServerAddress) + if err != nil { + return err + } + + logrus.Info("generating fixtures") + fixtures := l.generateFixtures() + logrus.Debug("done generating fixtures.") + + logrus.Info("starting sending requests") + wg := sync.WaitGroup{} + wg.Add(l.Config.Apps * l.Config.Clients) + appNameBuf := make([]byte, 25) + + for i := 0; i < l.Config.Apps; i++ { + // generate a random app name + l.Rand.Read(appNameBuf) + appName := hex.EncodeToString(appNameBuf) + for j := 0; j < l.Config.Clients; j++ { + go l.startClientThread(appName, &wg, fixtures[i]) + } + } + wg.Wait() + + logrus.Debug("done sending requests") + return nil +} + +func (l *LoadGen) generateFixtures() Fixtures { + var f Fixtures + + for i := 0; i < l.Config.Apps; i++ { + f = append(f, []*transporttrie.Trie{}) + + randomGen := rand.New(rand.NewSource(int64(l.Config.RandSeed + i))) + p := l.generateProfile(randomGen) + for j := 0; j < l.Config.Fixtures; j++ { + f[i] = append(f[i], p) + } + } + + return f +} + +func (l *LoadGen) startClientThread(appName string, wg *sync.WaitGroup, appFixtures []*transporttrie.Trie) { + rc := remote.RemoteConfig{ + UpstreamThreads: 1, + UpstreamAddress: l.Config.ServerAddress, + UpstreamRequestTimeout: 10 * time.Second, + } + r, err := remote.New(rc, logrus.StandardLogger()) + if err != nil { + panic(err) + } + + requestsCount := l.Config.Requests + + threadStartTime := time.Now().Truncate(10 * time.Second) + threadStartTime = threadStartTime.Add(time.Duration(-1*requestsCount) * (10 * time.Second)) + + st := threadStartTime + + for i := 0; i < requestsCount; i++ { + t := appFixtures[i%len(appFixtures)] + + st = st.Add(10 * time.Second) + et := st.Add(10 * time.Second) + err := r.UploadSync(&upstream.UploadJob{ + Name: appName + "{}", + StartTime: st, + EndTime: et, + SpyName: "gospy", + SampleRate: 100, + Units: "samples", + AggregationType: "sum", + Trie: t, + }) + if err != nil { + // TODO(eh-am): calculate errors + time.Sleep(time.Second) + } else { + // TODO(eh-am): calculate success + } + } + + wg.Done() +} + +func (l *LoadGen) generateProfile(randomGen *rand.Rand) *transporttrie.Trie { + t := transporttrie.New() + + for w := 0; w < l.Config.ProfileWidth; w++ { + symbol := []byte("root") + for d := 0; d < 2+l.Rand.Intn(l.Config.ProfileDepth); d++ { + randomGen.Read(l.SymbolBuf) + symbol = append(symbol, byte(';')) + symbol = append(symbol, []byte(hex.EncodeToString(l.SymbolBuf))...) + if l.Rand.Intn(100) <= 20 { + t.Insert(symbol, uint64(l.Rand.Intn(100)), true) + } + } + + t.Insert(symbol, uint64(l.Rand.Intn(100)), true) + } + return t +} + +// TODO(eh-am) exponential backoff and whatnot +func waitUntilEndpointReady(url string) error { + client := http.Client{Timeout: 10 * time.Second} + retries := 0 + + for { + _, err := client.Get(url) + + // all good? + if err == nil { + return nil + } + if retries >= MaxReadinessRetries { + break + } + + time.Sleep(time.Second) + retries++ + } + + return fmt.Errorf("maximum retries exceeded ('%d') waiting for server ('%s') to respond", retries, url) +} diff --git a/cmd/pyroscope/command/agent.go b/cmd/pyroscope/command/agent.go index 48d60b04f1..e77a90a769 100644 --- a/cmd/pyroscope/command/agent.go +++ b/cmd/pyroscope/command/agent.go @@ -14,7 +14,7 @@ func newAgentCmd(cfg *config.Agent) *cobra.Command { Short: "Start pyroscope agent", DisableFlagParsing: true, - RunE: createCmdRunFn(cfg, vpr, func(_ *cobra.Command, _ []string) error { + RunE: cli.CreateCmdRunFn(cfg, vpr, func(_ *cobra.Command, _ []string) error { return cli.StartAgent(cfg) }), } diff --git a/cmd/pyroscope/command/banner.go b/cmd/pyroscope/command/banner.go index 488f5bb75e..4dc347e6b9 100644 --- a/cmd/pyroscope/command/banner.go +++ b/cmd/pyroscope/command/banner.go @@ -1,10 +1,7 @@ package command import ( - "strings" - - "github.com/aybabtme/rgbterm" - "github.com/fatih/color" + "github.com/pyroscope-io/pyroscope/pkg/cli" ) // made here http://patorjk.com/software/taag/#p=display&f=Doom&t=Pyroscope @@ -23,34 +20,6 @@ func init() { banner = banner[1 : len(banner)-2] } -const ( - startColor = 0xffd651 - endColor = 0xf64d3d -) - -func gradient(start, end, offset int, progress float64) uint8 { - start = (start >> offset) & 0xff - end = (end >> offset) & 0xff - return uint8(start + int(float64(end-start)*progress)) -} - func gradientBanner() string { - if color.NoColor { - return banner + "\n" - } - - str := "" - arr := strings.Split(banner, "\n") - l := len(arr) - for i, line := range arr { - if line == "" { - break - } - progress := float64(i) / float64(l-1) - r := gradient(startColor, endColor, 16, progress) - g := gradient(startColor, endColor, 8, progress) - b := gradient(startColor, endColor, 0, progress) - str += rgbterm.FgString(line, r, g, b) + "\n" - } - return str + return cli.GradientBanner(banner) } diff --git a/cmd/pyroscope/command/command.go b/cmd/pyroscope/command/command.go index adb304ff31..0ca9d6c1a4 100644 --- a/cmd/pyroscope/command/command.go +++ b/cmd/pyroscope/command/command.go @@ -1,130 +1,10 @@ package command import ( - "fmt" - "os" - "strings" - - "github.com/spf13/cobra" - "github.com/spf13/pflag" - "github.com/spf13/viper" - "github.com/pyroscope-io/pyroscope/pkg/cli" - "github.com/pyroscope-io/pyroscope/pkg/config" - "github.com/pyroscope-io/pyroscope/pkg/util/slices" + "github.com/spf13/viper" ) -// https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html#tag_12_02 -const optionsEnd = "--" - -type cmdRunFn func(cmd *cobra.Command, args []string) error - -func createCmdRunFn(cfg interface{}, vpr *viper.Viper, fn cmdRunFn) cmdRunFn { - return func(cmd *cobra.Command, args []string) error { - var err error - if err = vpr.BindPFlags(cmd.Flags()); err != nil { - return err - } - if c, ok := cfg.(config.File); ok { - if err = loadConfigFile(c.Path(), cmd, vpr, cfg); err != nil { - return fmt.Errorf("loading configuration file: %w", err) - } - } - if err = cli.Unmarshal(vpr, cfg); err != nil { - return err - } - - var xargs []string - x := firstArgumentIndex(cmd.Flags(), prependDash(args)) - if x >= 0 { - xargs = args[:x] - args = args[x:] - } else { - xargs = args - args = nil - } - if err = cmd.Flags().Parse(prependDash(xargs)); err != nil { - return err - } - if slices.StringContains(xargs, "--help") { - _ = cmd.Help() - return nil - } - - if err = fn(cmd, args); err != nil { - cmd.SilenceUsage = true - } - return err - } -} - -func prependDash(args []string) []string { - for i, arg := range args { - if len(arg) > 2 && strings.HasPrefix(arg, "-") && !strings.HasPrefix(arg, "--") { - args[i] = "-" + arg - } - } - return args -} - -// firstArgumentIndex returns index of the first encountered argument. -// If args does not contain arguments, or contains undefined flags, -// the call returns -1. -func firstArgumentIndex(flags *pflag.FlagSet, args []string) int { - for i := 0; i < len(args); i++ { - a := args[i] - switch { - default: - return i - case a == optionsEnd: - return i + 1 - case strings.HasPrefix(a, optionsEnd) && len(a) > 2: - x := strings.SplitN(a[2:], "=", 2) - f := flags.Lookup(x[0]) - if f == nil { - return -1 - } - if f.Value.Type() == "bool" { - continue - } - if len(x) == 1 { - i++ - } - } - } - // Should have returned earlier. - return -1 -} - func newViper() *viper.Viper { - v := viper.New() - v.SetEnvPrefix("PYROSCOPE") - v.AutomaticEnv() - v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_")) - return v -} - -func loadConfigFile(path string, cmd *cobra.Command, vpr *viper.Viper, v interface{}) error { - if path == "" { - return nil - } - - vpr.SetConfigFile(path) - err := vpr.ReadInConfig() - switch { - case err == nil: - return nil - case isUserDefined(cmd.Flag("config"), vpr): - // User-defined configuration can not be read. - return err - case os.IsNotExist(err): - // Default configuration file not found. - return nil - default: - return err - } -} - -func isUserDefined(f *pflag.Flag, v *viper.Viper) bool { - return f.Changed || (f.DefValue != "" && f.DefValue != v.GetString(f.Name)) + return cli.NewViper("PYROSCOPE") } diff --git a/cmd/pyroscope/command/connect.go b/cmd/pyroscope/command/connect.go index e1ca6cac49..2f3d8b077e 100644 --- a/cmd/pyroscope/command/connect.go +++ b/cmd/pyroscope/command/connect.go @@ -15,7 +15,7 @@ func newConnectCmd(cfg *config.Exec) *cobra.Command { Short: "Connect to an existing process and profile it", DisableFlagParsing: true, - RunE: createCmdRunFn(cfg, vpr, func(_ *cobra.Command, args []string) error { + RunE: cli.CreateCmdRunFn(cfg, vpr, func(_ *cobra.Command, args []string) error { return exec.Cli(cfg, args) }), } diff --git a/cmd/pyroscope/command/convert.go b/cmd/pyroscope/command/convert.go index e16d564a9d..296dcb3599 100644 --- a/cmd/pyroscope/command/convert.go +++ b/cmd/pyroscope/command/convert.go @@ -22,7 +22,7 @@ func newConvertCmd(cfg *config.Convert) *cobra.Command { Hidden: true, DisableFlagParsing: true, - RunE: createCmdRunFn(cfg, vpr, func(_ *cobra.Command, _ []string) error { + RunE: cli.CreateCmdRunFn(cfg, vpr, func(_ *cobra.Command, _ []string) error { return parse(os.Stdin, cfg.Format) }), } diff --git a/cmd/pyroscope/command/dbmanager.go b/cmd/pyroscope/command/dbmanager.go index 07dcd2c3dc..3cdff31f50 100644 --- a/cmd/pyroscope/command/dbmanager.go +++ b/cmd/pyroscope/command/dbmanager.go @@ -17,7 +17,7 @@ func newDbManagerCmd(cfg *config.CombinedDbManager) *cobra.Command { Hidden: true, DisableFlagParsing: true, - RunE: createCmdRunFn(cfg, vpr, func(_ *cobra.Command, args []string) error { + RunE: cli.CreateCmdRunFn(cfg, vpr, func(_ *cobra.Command, args []string) error { return dbmanager.Cli(cfg.DbManager, cfg.Server, args) }), } diff --git a/cmd/pyroscope/command/exec.go b/cmd/pyroscope/command/exec.go index de51aa08d7..ce5f598fa0 100644 --- a/cmd/pyroscope/command/exec.go +++ b/cmd/pyroscope/command/exec.go @@ -19,7 +19,7 @@ func newExecCmd(cfg *config.Exec) *cobra.Command { Args: cobra.MinimumNArgs(1), DisableFlagParsing: true, - RunE: createCmdRunFn(cfg, vpr, func(_ *cobra.Command, args []string) error { + RunE: cli.CreateCmdRunFn(cfg, vpr, func(_ *cobra.Command, args []string) error { err := exec.Cli(cfg, args) // Normally, if the program ran, the call should return ExitError and // the exit code must be preserved. Otherwise, the error originates from diff --git a/cmd/pyroscope/command/exec_test.go b/cmd/pyroscope/command/exec_test.go index ed6d286f64..6ccf85b76d 100644 --- a/cmd/pyroscope/command/exec_test.go +++ b/cmd/pyroscope/command/exec_test.go @@ -38,7 +38,7 @@ func TestExecCommand(t *testing.T) { }, { description: "delimiter", - inputArgs: []string{optionsEnd}, + inputArgs: []string{cli.OptionsEnd}, expectedArgs: []string{}, }, { @@ -53,7 +53,7 @@ func TestExecCommand(t *testing.T) { }, { description: "exec_separated", - inputArgs: execArgs("-spy-name", "debugspy", optionsEnd), + inputArgs: execArgs("-spy-name", "debugspy", cli.OptionsEnd), expectedArgs: appArgs, }, { @@ -79,7 +79,7 @@ func TestExecCommand(t *testing.T) { cmd := &cobra.Command{ SilenceErrors: true, DisableFlagParsing: true, - RunE: createCmdRunFn(cfg, vpr, func(_ *cobra.Command, args []string) error { + RunE: cli.CreateCmdRunFn(cfg, vpr, func(_ *cobra.Command, args []string) error { cmdArgs = args return nil }), diff --git a/cmd/pyroscope/command/root.go b/cmd/pyroscope/command/root.go index 9787c4454e..4c9aa53700 100644 --- a/cmd/pyroscope/command/root.go +++ b/cmd/pyroscope/command/root.go @@ -74,7 +74,7 @@ func printUsageMessage(cmd *cobra.Command) error { func printHelpMessage(cmd *cobra.Command, _ []string) { cmd.Println(gradientBanner()) - cmd.Println(DefaultUsageFunc(cmd.Flags(), cmd)) + cmd.Println(cli.DefaultUsageFunc(cmd.Flags(), cmd)) } func addHelpSubcommand(cmd *cobra.Command) { diff --git a/cmd/pyroscope/command/server.go b/cmd/pyroscope/command/server.go index 8120673309..c7b12a286b 100644 --- a/cmd/pyroscope/command/server.go +++ b/cmd/pyroscope/command/server.go @@ -14,7 +14,7 @@ func newServerCmd(cfg *config.Server) *cobra.Command { Short: "Start pyroscope server. This is the database + web-based user interface", DisableFlagParsing: true, - RunE: createCmdRunFn(cfg, vpr, func(_ *cobra.Command, _ []string) error { + RunE: cli.CreateCmdRunFn(cfg, vpr, func(_ *cobra.Command, _ []string) error { return cli.StartServer(cfg) }), } diff --git a/cmd/pyroscope/logging.go b/cmd/pyroscope/logging.go index 4b5152ebbb..ed9fc91bc5 100644 --- a/cmd/pyroscope/logging.go +++ b/cmd/pyroscope/logging.go @@ -1,22 +1,9 @@ package main import ( - "log" - "os" - "runtime" - - "github.com/fatih/color" - "github.com/sirupsen/logrus" + "github.com/pyroscope-io/pyroscope/pkg/cli" ) func init() { - log.SetFlags(log.Lshortfile | log.Ldate | log.Ltime) - - logrus.SetFormatter(&logrus.TextFormatter{}) - logrus.SetOutput(os.Stdout) - logrus.SetLevel(logrus.DebugLevel) - - if runtime.GOOS == "windows" { - color.NoColor = true - } + cli.InitLogging() } diff --git a/pkg/cli/banner.go b/pkg/cli/banner.go new file mode 100644 index 0000000000..6da9ff74bf --- /dev/null +++ b/pkg/cli/banner.go @@ -0,0 +1,40 @@ +package cli + +import ( + "strings" + + "github.com/aybabtme/rgbterm" + "github.com/fatih/color" +) + +const ( + startColor = 0xffd651 + endColor = 0xf64d3d +) + +func GradientBanner(banner string) string { + if color.NoColor { + return banner + "\n" + } + + str := "" + arr := strings.Split(banner, "\n") + l := len(arr) + for i, line := range arr { + if line == "" { + break + } + progress := float64(i) / float64(l-1) + r := gradient(startColor, endColor, 16, progress) + g := gradient(startColor, endColor, 8, progress) + b := gradient(startColor, endColor, 0, progress) + str += rgbterm.FgString(line, r, g, b) + "\n" + } + return str +} + +func gradient(start, end, offset int, progress float64) uint8 { + start = (start >> offset) & 0xff + end = (end >> offset) & 0xff + return uint8(start + int(float64(end-start)*progress)) +} diff --git a/pkg/cli/command.go b/pkg/cli/command.go new file mode 100644 index 0000000000..3f58f20ff3 --- /dev/null +++ b/pkg/cli/command.go @@ -0,0 +1,129 @@ +package cli + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/spf13/viper" + + "github.com/pyroscope-io/pyroscope/pkg/config" + "github.com/pyroscope-io/pyroscope/pkg/util/slices" +) + +// https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html#tag_12_02 +const OptionsEnd = "--" + +type cmdRunFn func(cmd *cobra.Command, args []string) error + +func CreateCmdRunFn(cfg interface{}, vpr *viper.Viper, fn cmdRunFn) cmdRunFn { + return func(cmd *cobra.Command, args []string) error { + var err error + if err = vpr.BindPFlags(cmd.Flags()); err != nil { + return err + } + if c, ok := cfg.(config.File); ok { + if err = loadConfigFile(c.Path(), cmd, vpr, cfg); err != nil { + return fmt.Errorf("loading configuration file: %w", err) + } + } + if err = Unmarshal(vpr, cfg); err != nil { + return err + } + + var xargs []string + x := firstArgumentIndex(cmd.Flags(), prependDash(args)) + if x >= 0 { + xargs = args[:x] + args = args[x:] + } else { + xargs = args + args = nil + } + if err = cmd.Flags().Parse(prependDash(xargs)); err != nil { + return err + } + if slices.StringContains(xargs, "--help") { + _ = cmd.Help() + return nil + } + + if err = fn(cmd, args); err != nil { + cmd.SilenceUsage = true + } + return err + } +} + +func prependDash(args []string) []string { + for i, arg := range args { + if len(arg) > 2 && strings.HasPrefix(arg, "-") && !strings.HasPrefix(arg, "--") { + args[i] = "-" + arg + } + } + return args +} + +// firstArgumentIndex returns index of the first encountered argument. +// If args does not contain arguments, or contains undefined flags, +// the call returns -1. +func firstArgumentIndex(flags *pflag.FlagSet, args []string) int { + for i := 0; i < len(args); i++ { + a := args[i] + switch { + default: + return i + case a == OptionsEnd: + return i + 1 + case strings.HasPrefix(a, OptionsEnd) && len(a) > 2: + x := strings.SplitN(a[2:], "=", 2) + f := flags.Lookup(x[0]) + if f == nil { + return -1 + } + if f.Value.Type() == "bool" { + continue + } + if len(x) == 1 { + i++ + } + } + } + // Should have returned earlier. + return -1 +} + +func NewViper(prefix string) *viper.Viper { + v := viper.New() + v.SetEnvPrefix(prefix) + v.AutomaticEnv() + v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_")) + return v +} + +func loadConfigFile(path string, cmd *cobra.Command, vpr *viper.Viper, v interface{}) error { + if path == "" { + return nil + } + + vpr.SetConfigFile(path) + err := vpr.ReadInConfig() + switch { + case err == nil: + return nil + case isUserDefined(cmd.Flag("config"), vpr): + // User-defined configuration can not be read. + return err + case os.IsNotExist(err): + // Default configuration file not found. + return nil + default: + return err + } +} + +func isUserDefined(f *pflag.Flag, v *viper.Viper) bool { + return f.Changed || (f.DefValue != "" && f.DefValue != v.GetString(f.Name)) +} diff --git a/pkg/cli/logging.go b/pkg/cli/logging.go new file mode 100644 index 0000000000..f51647faa8 --- /dev/null +++ b/pkg/cli/logging.go @@ -0,0 +1,22 @@ +package cli + +import ( + "log" + "os" + "runtime" + + "github.com/fatih/color" + "github.com/sirupsen/logrus" +) + +func InitLogging() { + log.SetFlags(log.Lshortfile | log.Ldate | log.Ltime) + + logrus.SetFormatter(&logrus.TextFormatter{}) + logrus.SetOutput(os.Stdout) + logrus.SetLevel(logrus.DebugLevel) + + if runtime.GOOS == "windows" { + color.NoColor = true + } +} diff --git a/cmd/pyroscope/command/usage.go b/pkg/cli/usage.go similarity index 99% rename from cmd/pyroscope/command/usage.go rename to pkg/cli/usage.go index 6aef2ff963..fabeb2c081 100644 --- a/cmd/pyroscope/command/usage.go +++ b/pkg/cli/usage.go @@ -1,4 +1,4 @@ -package command +package cli import ( "fmt"