diff --git a/server/v2/commands.go b/server/v2/commands.go new file mode 100644 index 000000000000..86f8e400467c --- /dev/null +++ b/server/v2/commands.go @@ -0,0 +1,211 @@ +package serverv2 + +import ( + "context" + "errors" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "cosmossdk.io/core/transaction" +) + +// Execute executes the root command of an application. +// It handles adding core CLI flags, specifically the logging flags. +func Execute(rootCmd *cobra.Command, envPrefix, defaultHome string) error { + rootCmd.PersistentFlags().String(FlagLogLevel, "info", "The logging level (trace|debug|info|warn|error|fatal|panic|disabled or '*:,:')") + rootCmd.PersistentFlags().String(FlagLogFormat, "plain", "The logging format (json|plain)") + rootCmd.PersistentFlags().Bool(FlagLogNoColor, false, "Disable colored logs") + rootCmd.PersistentFlags().StringP(FlagHome, "", defaultHome, "directory for config and data") + + // update the global viper with the root command's configuration + viper.SetEnvPrefix(envPrefix) + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) + viper.AutomaticEnv() + + return rootCmd.Execute() +} + +// AddCommands add the server commands to the root command +// It configures the config handling and the logger handling +func AddCommands[T transaction.Tx]( + rootCmd *cobra.Command, + newApp AppCreator[T], + globalServerCfg ServerConfig, + components ...ServerComponent[T], +) error { + if len(components) == 0 { + return errors.New("no components provided") + } + + server := NewServer(globalServerCfg, components...) + originalPersistentPreRunE := rootCmd.PersistentPreRunE + rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + // set the default command outputs + cmd.SetOut(cmd.OutOrStdout()) + cmd.SetErr(cmd.ErrOrStderr()) + + if err := configHandle(server, cmd); err != nil { + return err + } + + // call the original PersistentPreRun(E) if it exists + if rootCmd.PersistentPreRun != nil { + rootCmd.PersistentPreRun(cmd, args) + return nil + } + + return originalPersistentPreRunE(cmd, args) + } + + cmds := server.CLICommands() + startCmd := createStartCommand(server, newApp) + startCmd.SetContext(rootCmd.Context()) + cmds.Commands = append(cmds.Commands, startCmd) + rootCmd.AddCommand(cmds.Commands...) + + if len(cmds.Queries) > 0 { + if queryCmd := findSubCommand(rootCmd, "query"); queryCmd != nil { + queryCmd.AddCommand(cmds.Queries...) + } else { + queryCmd := topLevelCmd(rootCmd.Context(), "query", "Querying subcommands") + queryCmd.Aliases = []string{"q"} + queryCmd.AddCommand(cmds.Queries...) + rootCmd.AddCommand(queryCmd) + } + } + + if len(cmds.Txs) > 0 { + if txCmd := findSubCommand(rootCmd, "tx"); txCmd != nil { + txCmd.AddCommand(cmds.Txs...) + } else { + txCmd := topLevelCmd(rootCmd.Context(), "tx", "Transactions subcommands") + txCmd.AddCommand(cmds.Txs...) + rootCmd.AddCommand(txCmd) + } + } + + return nil +} + +// createStartCommand creates the start command for the application. +func createStartCommand[T transaction.Tx]( + server *Server[T], + newApp AppCreator[T], +) *cobra.Command { + flags := server.StartFlags() + + cmd := &cobra.Command{ + Use: "start", + Short: "Run the application", + RunE: func(cmd *cobra.Command, args []string) error { + v := GetViperFromCmd(cmd) + l := GetLoggerFromCmd(cmd) + if err := v.BindPFlags(cmd.Flags()); err != nil { + return err + } + + if err := server.Init(newApp(l, v), v.AllSettings(), l); err != nil { + return err + } + + ctx, cancelFn := context.WithCancel(cmd.Context()) + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + sig := <-sigCh + cancelFn() + cmd.Printf("caught %s signal\n", sig.String()) + + if err := server.Stop(ctx); err != nil { + cmd.PrintErrln("failed to stop servers:", err) + } + }() + + if err := server.Start(ctx); err != nil { + return err + } + + return nil + }, + } + + // add the start flags to the command + for _, startFlags := range flags { + cmd.Flags().AddFlagSet(startFlags) + } + + return cmd +} + +// configHandle writes the default config to the home directory if it does not exist and sets the server context +func configHandle[T transaction.Tx](s *Server[T], cmd *cobra.Command) error { + home, err := cmd.Flags().GetString(FlagHome) + if err != nil { + return err + } + + configDir := filepath.Join(home, "config") + + // we need to check app.toml as the config folder can already exist for the client.toml + if _, err := os.Stat(filepath.Join(configDir, "app.toml")); os.IsNotExist(err) { + if err = s.WriteConfig(configDir); err != nil { + return err + } + } + + v, err := ReadConfig(configDir) + if err != nil { + return err + } + + if err := v.BindPFlags(cmd.Flags()); err != nil { + return err + } + + logger, err := NewLogger(v, cmd.OutOrStdout()) + if err != nil { + return err + } + + return SetCmdServerContext(cmd, v, logger) +} + +// findSubCommand finds a sub-command of the provided command whose Use +// string is or begins with the provided subCmdName. +// It verifies the command's aliases as well. +func findSubCommand(cmd *cobra.Command, subCmdName string) *cobra.Command { + for _, subCmd := range cmd.Commands() { + use := subCmd.Use + if use == subCmdName || strings.HasPrefix(use, subCmdName+" ") { + return subCmd + } + + for _, alias := range subCmd.Aliases { + if alias == subCmdName || strings.HasPrefix(alias, subCmdName+" ") { + return subCmd + } + } + } + return nil +} + +// topLevelCmd creates a new top-level command with the provided name and +// description. The command will have DisableFlagParsing set to false and +// SuggestionsMinimumDistance set to 2. +func topLevelCmd(ctx context.Context, use, short string) *cobra.Command { + cmd := &cobra.Command{ + Use: use, + Short: short, + DisableFlagParsing: false, + SuggestionsMinimumDistance: 2, + } + cmd.SetContext(ctx) + + return cmd +} diff --git a/server/v2/server.go b/server/v2/server.go new file mode 100644 index 000000000000..486f5c0ceecb --- /dev/null +++ b/server/v2/server.go @@ -0,0 +1,259 @@ +package serverv2 + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/pelletier/go-toml/v2" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "golang.org/x/sync/errgroup" + + "cosmossdk.io/core/transaction" + "cosmossdk.io/log" +) + +// ServerComponent is a server module that can be started and stopped. +type ServerComponent[T transaction.Tx] interface { + Name() string + + Start(context.Context) error + Stop(context.Context) error + Init(AppI[T], map[string]any, log.Logger) error +} + +// HasStartFlags is a server module that has start flags. +type HasStartFlags interface { + // StartCmdFlags returns server start flags. + // Those flags should be prefixed with the server name. + // They are then merged with the server config in one viper instance. + StartCmdFlags() *pflag.FlagSet +} + +// HasConfig is a server module that has a config. +type HasConfig interface { + Config() any +} + +// HasCLICommands is a server module that has CLI commands. +type HasCLICommands interface { + CLICommands() CLIConfig +} + +// CLIConfig defines the CLI configuration for a module server. +type CLIConfig struct { + // Commands defines the main command of a module server. + Commands []*cobra.Command + // Queries defines the query commands of a module server. + // Those commands are meant to be added in the root query command. + Queries []*cobra.Command + // Txs defines the tx commands of a module server. + // Those commands are meant to be added in the root tx command. + Txs []*cobra.Command +} + +const ( + serverName = "server" +) + +var _ ServerComponent[transaction.Tx] = (*Server[transaction.Tx])(nil) + +// Server is the top-level server component which contains all other server components. +type Server[T transaction.Tx] struct { + components []ServerComponent[T] + config ServerConfig +} + +func NewServer[T transaction.Tx]( + config ServerConfig, + components ...ServerComponent[T], +) *Server[T] { + return &Server[T]{ + config: config, + components: components, + } +} + +func (s *Server[T]) Name() string { + return serverName +} + +// Start starts all components concurrently. +func (s *Server[T]) Start(ctx context.Context) error { + logger := GetLoggerFromContext(ctx) + logger.With(log.ModuleKey, s.Name()).Info("starting servers...") + + g, ctx := errgroup.WithContext(ctx) + for _, mod := range s.components { + g.Go(func() error { + return mod.Start(ctx) + }) + } + + if err := g.Wait(); err != nil { + return fmt.Errorf("failed to start servers: %w", err) + } + + <-ctx.Done() + + return nil +} + +// Stop stops all components concurrently. +func (s *Server[T]) Stop(ctx context.Context) error { + logger := GetLoggerFromContext(ctx) + logger.With(log.ModuleKey, s.Name()).Info("stopping servers...") + + g, ctx := errgroup.WithContext(ctx) + for _, mod := range s.components { + g.Go(func() error { + return mod.Stop(ctx) + }) + } + + return g.Wait() +} + +// CLICommands returns all CLI commands of all components. +func (s *Server[T]) CLICommands() CLIConfig { + compart := func(name string, cmds ...*cobra.Command) *cobra.Command { + if len(cmds) == 1 && strings.HasPrefix(cmds[0].Use, name) { + return cmds[0] + } + + subCmd := &cobra.Command{ + Use: name, + Short: fmt.Sprintf("Commands from the %s server component", name), + } + subCmd.AddCommand(cmds...) + + return subCmd + } + + commands := CLIConfig{} + for _, mod := range s.components { + if climod, ok := mod.(HasCLICommands); ok { + srvCmd := climod.CLICommands() + + if len(srvCmd.Commands) > 0 { + commands.Commands = append(commands.Commands, compart(mod.Name(), srvCmd.Commands...)) + } + + if len(srvCmd.Txs) > 0 { + commands.Txs = append(commands.Txs, compart(mod.Name(), srvCmd.Txs...)) + } + + if len(srvCmd.Queries) > 0 { + commands.Queries = append(commands.Queries, compart(mod.Name(), srvCmd.Queries...)) + } + } + } + + return commands +} + +// Config returns config of the server component +func (s *Server[T]) Config() ServerConfig { + return s.config +} + +// Configs returns all configs of all server components. +func (s *Server[T]) Configs() map[string]any { + cfgs := make(map[string]any) + + // add server component config + cfgs[s.Name()] = s.config + + // add other components' config + for _, mod := range s.components { + if configmod, ok := mod.(HasConfig); ok { + cfg := configmod.Config() + cfgs[mod.Name()] = cfg + } + } + + return cfgs +} + +func (s *Server[T]) StartCmdFlags() *pflag.FlagSet { + flags := pflag.NewFlagSet(s.Name(), pflag.ExitOnError) + flags.String(FlagMinGasPrices, "", "Minimum gas prices to accept for transactions; Any fee in a tx must meet this minimum (e.g. 0.01photino;0.0001stake)") + return flags +} + +// Init initializes all server components with the provided application, configuration, and logger. +// It returns an error if any component fails to initialize. +func (s *Server[T]) Init(appI AppI[T], cfg map[string]any, logger log.Logger) error { + serverCfg := s.config + if len(cfg) > 0 { + if err := UnmarshalSubConfig(cfg, s.Name(), &serverCfg); err != nil { + return fmt.Errorf("failed to unmarshal config: %w", err) + } + } + + var components []ServerComponent[T] + for _, mod := range s.components { + if err := mod.Init(appI, cfg, logger); err != nil { + return err + } + + components = append(components, mod) + } + + s.config = serverCfg + s.components = components + return nil +} + +// WriteConfig writes the config to the given path. +// Note: it does not use viper.WriteConfigAs because we do not want to store flag values in the config. +func (s *Server[T]) WriteConfig(configPath string) error { + cfgs := s.Configs() + b, err := toml.Marshal(cfgs) + if err != nil { + return err + } + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + if err := os.MkdirAll(configPath, os.ModePerm); err != nil { + return err + } + } + + if err := os.WriteFile(filepath.Join(configPath, "app.toml"), b, 0o600); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + + for _, component := range s.components { + // undocumented interface to write the component default config in another file than app.toml + // it is used by cometbft for backward compatibility + // it should not be used by other components + if mod, ok := component.(interface{ WriteCustomConfigAt(string) error }); ok { + if err := mod.WriteCustomConfigAt(configPath); err != nil { + return err + } + } + } + + return nil +} + +// StartFlags returns all flags of all server components. +func (s *Server[T]) StartFlags() []*pflag.FlagSet { + flags := []*pflag.FlagSet{} + + // add server component flags + flags = append(flags, s.StartCmdFlags()) + + // add other components' start cmd flags + for _, mod := range s.components { + if startmod, ok := mod.(HasStartFlags); ok { + flags = append(flags, startmod.StartCmdFlags()) + } + } + + return flags +} diff --git a/server/v2/server_test.go b/server/v2/server_test.go new file mode 100644 index 000000000000..c35dea1fc627 --- /dev/null +++ b/server/v2/server_test.go @@ -0,0 +1,115 @@ +package serverv2_test + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + gogoproto "github.com/cosmos/gogoproto/proto" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + + appmodulev2 "cosmossdk.io/core/appmodule/v2" + coreserver "cosmossdk.io/core/server" + "cosmossdk.io/core/transaction" + "cosmossdk.io/log" + serverv2 "cosmossdk.io/server/v2" + grpc "cosmossdk.io/server/v2/api/grpc" + "cosmossdk.io/server/v2/appmanager" + "cosmossdk.io/server/v2/store" + storev2 "cosmossdk.io/store/v2" +) + +type mockInterfaceRegistry struct{} + +func (*mockInterfaceRegistry) Resolve(typeUrl string) (gogoproto.Message, error) { + panic("not implemented") +} + +func (*mockInterfaceRegistry) ListImplementations(ifaceTypeURL string) []string { + panic("not implemented") +} +func (*mockInterfaceRegistry) ListAllInterfaces() []string { panic("not implemented") } + +type mockApp[T transaction.Tx] struct { + serverv2.AppI[T] +} + +func (*mockApp[T]) GetQueryHandlers() map[string]appmodulev2.Handler { + return map[string]appmodulev2.Handler{} +} + +func (*mockApp[T]) GetAppManager() *appmanager.AppManager[T] { + return nil +} + +func (*mockApp[T]) InterfaceRegistry() coreserver.InterfaceRegistry { + return &mockInterfaceRegistry{} +} + +func (*mockApp[T]) GetStore() storev2.RootStore { + return nil +} + +func TestServer(t *testing.T) { + currentDir, err := os.Getwd() + require.NoError(t, err) + configPath := filepath.Join(currentDir, "testdata") + + v, err := serverv2.ReadConfig(configPath) + if err != nil { + v = viper.New() + } + cfg := v.AllSettings() + + logger := log.NewLogger(os.Stdout) + + ctx, err := serverv2.SetServerContext(context.Background(), v, logger) + require.NoError(t, err) + + grpcServer := grpc.New[transaction.Tx]() + err = grpcServer.Init(&mockApp[transaction.Tx]{}, cfg, logger) + require.NoError(t, err) + + storeServer := store.New[transaction.Tx]() + err = storeServer.Init(&mockApp[transaction.Tx]{}, cfg, logger) + require.NoError(t, err) + + mockServer := &mockServer{name: "mock-server-1", ch: make(chan string, 100)} + + server := serverv2.NewServer( + serverv2.DefaultServerConfig(), + grpcServer, + storeServer, + mockServer, + ) + + serverCfgs := server.Configs() + require.Equal(t, serverCfgs[grpcServer.Name()].(*grpc.Config).Address, grpc.DefaultConfig().Address) + require.Equal(t, serverCfgs[mockServer.Name()].(*mockServerConfig).MockFieldOne, MockServerDefaultConfig().MockFieldOne) + + // write config + err = server.WriteConfig(configPath) + require.NoError(t, err) + + v, err = serverv2.ReadConfig(configPath) + require.NoError(t, err) + + require.Equal(t, v.GetString(grpcServer.Name()+".address"), grpc.DefaultConfig().Address) + + // start empty + ctx, cancelFn := context.WithCancel(ctx) + go func() { + // wait 5sec and cancel context + <-time.After(5 * time.Second) + cancelFn() + + err = server.Stop(ctx) + require.NoError(t, err) + }() + + err = server.Start(ctx) + require.NoError(t, err) +} diff --git a/server/v2/util.go b/server/v2/util.go new file mode 100644 index 000000000000..d02ea30125e5 --- /dev/null +++ b/server/v2/util.go @@ -0,0 +1,124 @@ +package serverv2 + +import ( + "context" + "errors" + "fmt" + "net" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + corectx "cosmossdk.io/core/context" + "cosmossdk.io/log" +) + +// SetServerContext sets the logger and viper in the context. +// The server manager expects the logger and viper to be set in the context. +func SetServerContext(ctx context.Context, viper *viper.Viper, logger log.Logger) (context.Context, error) { + if ctx == nil { + ctx = context.Background() + } + + ctx = context.WithValue(ctx, corectx.LoggerContextKey, logger) + ctx = context.WithValue(ctx, corectx.ViperContextKey, viper) + return ctx, nil +} + +// SetCmdServerContext sets a command's Context value to the provided argument. +// The server manager expects the logger and viper to be set in the context. +// If the context has not been set, set the given context as the default. +func SetCmdServerContext(cmd *cobra.Command, viper *viper.Viper, logger log.Logger) error { + ctx, err := SetServerContext(cmd.Context(), viper, logger) + if err != nil { + return err + } + cmd.SetContext(ctx) + return nil +} + +// GetViperFromContext returns the viper instance from the context. +func GetViperFromContext(ctx context.Context) *viper.Viper { + value := ctx.Value(corectx.ViperContextKey) + v, ok := value.(*viper.Viper) + if !ok { + panic(fmt.Sprintf("incorrect viper type %T: expected *viper.Viper. Have you forgot to set the viper in the context?", value)) + } + return v +} + +// GetViperFromCmd returns the viper instance from the command context. +func GetViperFromCmd(cmd *cobra.Command) *viper.Viper { + return GetViperFromContext(cmd.Context()) +} + +// GetLoggerFromContext returns the logger instance from the context. +func GetLoggerFromContext(ctx context.Context) log.Logger { + v := ctx.Value(corectx.LoggerContextKey) + logger, ok := v.(log.Logger) + if !ok { + panic(fmt.Sprintf("incorrect logger type %T: expected log.Logger. Have you forgot to set the logger in the context?", v)) + } + + return logger +} + +// GetLoggerFromCmd returns the logger instance from the command context. +func GetLoggerFromCmd(cmd *cobra.Command) log.Logger { + return GetLoggerFromContext(cmd.Context()) +} + +// ExternalIP https://stackoverflow.com/questions/23558425/how-do-i-get-the-local-ip-address-in-go +func ExternalIP() (string, error) { + ifaces, err := net.Interfaces() + if err != nil { + return "", err + } + + for _, iface := range ifaces { + if skipInterface(iface) { + continue + } + addrs, err := iface.Addrs() + if err != nil { + return "", err + } + + for _, addr := range addrs { + ip := addrToIP(addr) + if ip == nil || ip.IsLoopback() { + continue + } + ip = ip.To4() + if ip == nil { + continue // not an ipv4 address + } + return ip.String(), nil + } + } + return "", errors.New("are you connected to the network?") +} + +func skipInterface(iface net.Interface) bool { + if iface.Flags&net.FlagUp == 0 { + return true // interface down + } + + if iface.Flags&net.FlagLoopback != 0 { + return true // loopback interface + } + + return false +} + +func addrToIP(addr net.Addr) net.IP { + var ip net.IP + + switch v := addr.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + } + return ip +} diff --git a/simapp/v2/simdv2/cmd/commands.go b/simapp/v2/simdv2/cmd/commands.go index 3fcbed114d9e..8d4186be8d19 100644 --- a/simapp/v2/simdv2/cmd/commands.go +++ b/simapp/v2/simdv2/cmd/commands.go @@ -55,11 +55,6 @@ func initRootCmd[T transaction.Tx]( NewTestnetCmd(moduleManager), ) - logger, err := serverv2.NewLogger(viper.New(), rootCmd.OutOrStdout()) - if err != nil { - panic(fmt.Sprintf("failed to create logger: %v", err)) - } - // add keybase, auxiliary RPC, query, genesis, and tx child commands rootCmd.AddCommand( genesisCommand(moduleManager), @@ -70,10 +65,9 @@ func initRootCmd[T transaction.Tx]( ) // wire server commands - if err = serverv2.AddCommands( + if err := serverv2.AddCommands( rootCmd, newApp, - logger, initServerConfig(), cometbft.New( &genericTxDecoder[T]{txConfig}, diff --git a/simapp/v2/simdv2/cmd/config.go b/simapp/v2/simdv2/cmd/config.go index 51a7adb178e9..16cb19db93e7 100644 --- a/simapp/v2/simdv2/cmd/config.go +++ b/simapp/v2/simdv2/cmd/config.go @@ -79,7 +79,7 @@ func initCometConfig() cometbft.CfgOption { cfg := cmtcfg.DefaultConfig() // display only warn logs by default except for p2p and state - cfg.LogLevel = "*:warn,p2p:info,state:info" + cfg.LogLevel = "*:warn,server:info,p2p:info,state:info" // increase block timeout cfg.Consensus.TimeoutCommit = 5 * time.Second // overwrite default pprof listen address diff --git a/simapp/v2/simdv2/cmd/testnet.go b/simapp/v2/simdv2/cmd/testnet.go index 6e94bdfc801c..7a34d6a795af 100644 --- a/simapp/v2/simdv2/cmd/testnet.go +++ b/simapp/v2/simdv2/cmd/testnet.go @@ -15,7 +15,6 @@ import ( "github.com/spf13/pflag" "cosmossdk.io/core/transaction" - "cosmossdk.io/log" "cosmossdk.io/math" "cosmossdk.io/math/unsafe" runtimev2 "cosmossdk.io/runtime/v2" @@ -343,7 +342,7 @@ func initTestnetFiles[T transaction.Tx]( ) storeServer := store.New[T]() grpcServer := grpc.New[T](grpc.OverwriteDefaultConfig(grpcConfig)) - server := serverv2.NewServer[T](log.NewNopLogger(), serverCfg, cometServer, grpcServer, storeServer) + server := serverv2.NewServer[T](serverCfg, cometServer, grpcServer, storeServer) err = server.WriteConfig(filepath.Join(nodeDir, "config")) if err != nil { return err