diff --git a/cmd/world/cardinal/cardinal.go b/cmd/world/cardinal/cardinal.go index 4b54a94..dd16c58 100644 --- a/cmd/world/cardinal/cardinal.go +++ b/cmd/world/cardinal/cardinal.go @@ -1,11 +1,8 @@ package cardinal import ( - "errors" - "github.com/rs/zerolog/log" "github.com/spf13/cobra" - "pkg.world.dev/world-cli/common/config" "pkg.world.dev/world-cli/common/dependency" "pkg.world.dev/world-cli/tea/style" ) @@ -13,7 +10,6 @@ import ( func init() { // Register subcommands - `world cardinal [subcommand]` BaseCmd.AddCommand(createCmd, startCmd, devCmd, restartCmd, purgeCmd, stopCmd) - BaseCmd.Flags().String("config", "", "a toml encoded config file") } // BaseCmd is the base command for the cardinal subcommand @@ -38,20 +34,3 @@ var BaseCmd = &cobra.Command{ } }, } - -func getConfig(cmd *cobra.Command) (cfg config.Config, err error) { - if !cmd.Flags().Changed("config") { - // The config flag was not set. Attempt to find the config via environment variables or in the local directory - return config.LoadConfig("") - } - // The config flag was explicitly set - configFile, err := cmd.Flags().GetString("config") - if err != nil { - return cfg, err - } - if configFile == "" { - return cfg, errors.New("config cannot be empty") - } - return config.LoadConfig(configFile) - -} diff --git a/cmd/world/cardinal/restart.go b/cmd/world/cardinal/restart.go index 407cbe4..ca15f70 100644 --- a/cmd/world/cardinal/restart.go +++ b/cmd/world/cardinal/restart.go @@ -2,6 +2,7 @@ package cardinal import ( "github.com/spf13/cobra" + "pkg.world.dev/world-cli/common/config" "pkg.world.dev/world-cli/common/tea_cmd" ) @@ -20,7 +21,7 @@ This will restart the following Docker services: - Cardinal (Core game logic) - Nakama (Relay)`, RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := getConfig(cmd) + cfg, err := config.GetConfig(cmd) if err != nil { return err } diff --git a/cmd/world/cardinal/start.go b/cmd/world/cardinal/start.go index 083b2bb..6b1d11c 100644 --- a/cmd/world/cardinal/start.go +++ b/cmd/world/cardinal/start.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/spf13/cobra" + "pkg.world.dev/world-cli/common/config" "pkg.world.dev/world-cli/common/tea_cmd" ) @@ -35,7 +36,7 @@ This will start the following Docker services and its dependencies: - Nakama (Relay) - Redis (Cardinal dependency)`, RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := getConfig(cmd) + cfg, err := config.GetConfig(cmd) if err != nil { return err } diff --git a/cmd/world/evm/evm.go b/cmd/world/evm/evm.go new file mode 100644 index 0000000..49670da --- /dev/null +++ b/cmd/world/evm/evm.go @@ -0,0 +1,217 @@ +package evm + +import ( + "bytes" + "errors" + "fmt" + "os/exec" + "strings" + "time" + + "github.com/spf13/cobra" + "pkg.world.dev/world-cli/common/config" + "pkg.world.dev/world-cli/common/tea_cmd" +) + +func EVMCmds() *cobra.Command { + evmRootCmd := &cobra.Command{ + Use: "evm", + Short: "EVM base shard commands.", + Long: "Commands for provisioning the EVM Base Shard.", + } + evmRootCmd.AddGroup(&cobra.Group{ + ID: "EVM", + Title: "EVM Base Shard Commands", + }) + evmRootCmd.AddCommand( + StartEVM(), + StopAll(), + ) + return evmRootCmd +} + +const ( + FlagUseDevDA = "dev" + FlagDAAuthToken = "da-auth-token" + EnvDAAuthToken = "DA_AUTH_TOKEN" + EnvDABaseURL = "DA_BASE_URL" + EnvDANamespaceID = "DA_NAMESPACE_ID" + + daService = tea_cmd.DockerServiceDA +) + +var ( + // Docker compose seems to replace the hyphen with an underscore. This could be properly fixed by removing the hyphen + // from celesta-devnet, or by investigating the docker compose documentation. + daContainer = strings.ReplaceAll(string(daService), "-", "_") +) + +func services(s ...tea_cmd.DockerService) []tea_cmd.DockerService { + return s +} + +// validateDevDALayer starts a locally running version of the DA layer, and replaces the DA_AUTH_TOKEN configuration +// variable with the token from the locally running container. +func validateDevDALayer(cfg config.Config) error { + cfg.Build = true + cfg.Debug = false + cfg.Detach = true + cfg.Timeout = -1 + fmt.Println("starting DA docker service for dev mode...") + if err := tea_cmd.DockerStart(cfg, services(daService)); err != nil { + return fmt.Errorf("error starting %s docker container: %w", daService, err) + } + + if err := blockUntilContainerIsRunning(daContainer, 10*time.Second); err != nil { + return err + } + fmt.Println("started DA service...") + + daToken, err := getDAToken() + if err != nil { + return err + } + envOverrides := map[string]string{ + EnvDAAuthToken: daToken, + EnvDABaseURL: fmt.Sprintf("http://%s:26658", daService), + EnvDANamespaceID: "67480c4a88c4d12935d4", + } + for key, value := range envOverrides { + fmt.Printf("overriding config value %q to %q\n", key, value) + cfg.DockerEnv[key] = value + } + return nil +} + +// validateProdDALayer assumes the DA layer is running somewhere else and validates the required world.toml variables are +// non-empty. +func validateProdDALayer(cfg config.Config) error { + requiredEnvVariables := []string{ + EnvDAAuthToken, + EnvDABaseURL, + EnvDANamespaceID, + } + var errs []error + for _, env := range requiredEnvVariables { + if len(cfg.DockerEnv[env]) > 0 { + continue + } + errs = append(errs, fmt.Errorf("missing %q", env)) + } + if len(errs) > 0 { + // Prepend an error describing the overall problem + errs = append([]error{ + fmt.Errorf("the [evm] section of your config is missing some required variables"), + }, errs...) + return errors.Join(errs...) + } + return nil +} + +func validateDALayer(cmd *cobra.Command, cfg config.Config) error { + devDA, err := cmd.Flags().GetBool(FlagUseDevDA) + if err != nil { + return err + } + if devDA { + return validateDevDALayer(cfg) + } + return validateProdDALayer(cfg) +} + +func StartEVM() *cobra.Command { + cmd := &cobra.Command{ + Use: "start", + Short: "Start the EVM base shard. Use --da-auth-token to pass in an auth token directly.", + Long: "Start the EVM base shard. Requires connection to celestia DA.", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.GetConfig(cmd) + if err != nil { + return err + } + + if err = validateDALayer(cmd, cfg); err != nil { + return err + } + + daToken, err := cmd.Flags().GetString(FlagDAAuthToken) + if err != nil { + return err + } + if daToken != "" { + cfg.DockerEnv[EnvDAAuthToken] = daToken + } + + cfg.Build = true + cfg.Debug = false + cfg.Detach = false + cfg.Timeout = 0 + + err = tea_cmd.DockerStart(cfg, services(tea_cmd.DockerServiceEVM)) + if err != nil { + fmt.Errorf("error starting %s docker container: %w", tea_cmd.DockerServiceEVM, err) + } + return nil + }, + } + cmd.Flags().String(FlagDAAuthToken, "", "DA Auth Token that allows the rollup to communicate with the Celestia client.") + cmd.Flags().Bool(FlagUseDevDA, false, "Use a locally running DA layer") + return cmd +} + +func StopAll() *cobra.Command { + cmd := &cobra.Command{ + Use: "stop", + Short: "Stop the EVM base shard and DA layer client.", + Long: "Stop the EVM base shard and data availability layer client if they are running.", + RunE: func(cmd *cobra.Command, args []string) error { + return tea_cmd.DockerStop(services(tea_cmd.DockerServiceEVM, tea_cmd.DockerServiceDA)) + }, + } + return cmd +} + +func getDAToken() (token string, err error) { + // Create a new command + maxRetries := 10 + cmdString := fmt.Sprintf("docker exec %s celestia bridge --node.store /home/celestia/bridge/ auth admin", daContainer) + cmdParts := strings.Split(cmdString, " ") + for retry := 0; retry < maxRetries; retry++ { + fmt.Println("attempting to get DA token...") + + cmd := exec.Command(cmdParts[0], cmdParts[1:]...) + output, err := cmd.CombinedOutput() + if err != nil { + fmt.Println("failed to get da token") + fmt.Printf("%d/%d retrying...\n", retry+1, maxRetries) + time.Sleep(2 * time.Second) + continue + } + + if bytes.Contains(output, []byte("\n")) { + return "", fmt.Errorf("da token should be a single line. got %v", string(output)) + } + if len(output) == 0 { + return "", fmt.Errorf("got empty DA token") + } + return string(output), nil + } + return "", fmt.Errorf("timed out while getting DA token") + +} + +func blockUntilContainerIsRunning(targetContainer string, timeout time.Duration) error { + timeoutAt := time.Now().Add(timeout) + cmdString := "docker container inspect -f '{{.State.Running}}' " + targetContainer + // This string will be returned by the above command when the container is running + runningOutput := "'true'\n" + cmdParts := strings.Split(cmdString, " ") + for time.Now().Before(timeoutAt) { + output, err := exec.Command(cmdParts[0], cmdParts[1:]...).CombinedOutput() + if err == nil && string(output) == runningOutput { + return nil + } + time.Sleep(250 * time.Millisecond) + } + return fmt.Errorf("timeout while waiting for %q to start", targetContainer) +} diff --git a/cmd/world/root/root.go b/cmd/world/root/root.go index ca94c09..442e04d 100644 --- a/cmd/world/root/root.go +++ b/cmd/world/root/root.go @@ -1,11 +1,14 @@ package root import ( + "os" + "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/spf13/cobra" - "os" "pkg.world.dev/world-cli/cmd/world/cardinal" + "pkg.world.dev/world-cli/cmd/world/evm" + "pkg.world.dev/world-cli/common/config" "pkg.world.dev/world-cli/tea/style" ) @@ -21,6 +24,10 @@ func init() { // Register subcommands rootCmd.AddCommand(cardinal.BaseCmd) + + rootCmd.AddCommand(evm.EVMCmds()) + + config.AddConfigFlag(rootCmd) } // rootCmd represents the base command diff --git a/common/config/config.go b/common/config/config.go index f3d481a..94ad0d1 100644 --- a/common/config/config.go +++ b/common/config/config.go @@ -9,11 +9,14 @@ import ( "github.com/pelletier/go-toml" "github.com/rs/zerolog/log" + "github.com/spf13/cobra" ) const ( WorldCLIConfigFileEnvVariable = "WORLD_CLI_CONFIG_FILE" WorldCLIConfigFilename = "world.toml" + + flagForConfigFile = "config" ) var ( @@ -32,7 +35,27 @@ type Config struct { DockerEnv map[string]string } -func LoadConfig(filename string) (Config, error) { +func AddConfigFlag(cmd *cobra.Command) { + cmd.Flags().String(flagForConfigFile, "", "a toml encoded config file") +} + +func GetConfig(cmd *cobra.Command) (cfg Config, err error) { + if !cmd.Flags().Changed(flagForConfigFile) { + // The config flag was not set. Attempt to find the config via environment variables or in the local directory + return loadConfig("") + } + // The config flag was explicitly set + configFile, err := cmd.Flags().GetString(flagForConfigFile) + if err != nil { + return cfg, err + } + if configFile == "" { + return cfg, errors.New("config cannot be empty") + } + return loadConfig(configFile) +} + +func loadConfig(filename string) (Config, error) { if filename != "" { return loadConfigFromFile(filename) } diff --git a/common/config/config_test.go b/common/config/config_test.go index e8a202f..0e9fd5e 100644 --- a/common/config/config_test.go +++ b/common/config/config_test.go @@ -7,9 +7,24 @@ import ( "testing" "github.com/pelletier/go-toml" + "github.com/spf13/cobra" "gotest.tools/v3/assert" ) +// cmdZero returns an empty cobra command. Since the config flag is not set, GetConfig will search +// the local directory (and parent directories) for a config file. +func cmdZero() *cobra.Command { + return &cobra.Command{} +} + +// cmdWithConfig creates a command that has the --config flag set to the given filename +func cmdWithConfig(filename string) *cobra.Command { + cmd := cmdZero() + AddConfigFlag(cmd) + cmd.Flags().Set(flagForConfigFile, filename) + return cmd +} + func getNamespace(t *testing.T, cfg Config) string { val, ok := cfg.DockerEnv["CARDINAL_NAMESPACE"] assert.Check(t, ok, "no CARDINAL_NAMESPACE field found") @@ -45,7 +60,7 @@ func makeConfigAtFile(t *testing.T, file *os.File, namespace string) { func TestCanSetNamespaceWithFilename(t *testing.T) { file := makeConfigAtTemp(t, "alpha") - cfg, err := LoadConfig(file) + cfg, err := GetConfig(cmdWithConfig(file)) assert.NilError(t, err) assert.Equal(t, "alpha", getNamespace(t, cfg)) } @@ -61,7 +76,7 @@ func replaceEnvVarForTest(t *testing.T, env, value string) { func TestCanSetNamespaceWithEnvVariable(t *testing.T) { file := makeConfigAtTemp(t, "alpha") replaceEnvVarForTest(t, WorldCLIConfigFileEnvVariable, file) - cfg, err := LoadConfig("") + cfg, err := GetConfig(cmdZero()) assert.NilError(t, err) assert.Equal(t, "alpha", getNamespace(t, cfg)) } @@ -70,7 +85,7 @@ func TestConfigPreference(t *testing.T) { fileConfig := makeConfigAtTemp(t, "alpha") envConfig := makeConfigAtTemp(t, "beta") replaceEnvVarForTest(t, WorldCLIConfigFileEnvVariable, envConfig) - cfg, err := LoadConfig(fileConfig) + cfg, err := GetConfig(cmdWithConfig(fileConfig)) assert.NilError(t, err) assert.Equal(t, "alpha", getNamespace(t, cfg)) } @@ -99,7 +114,7 @@ func TestConfigFromLocalFile(t *testing.T) { configFile := path.Join(tempdir, WorldCLIConfigFilename) makeConfigAtPath(t, configFile, "alpha") - cfg, err := LoadConfig("") + cfg, err := GetConfig(cmdZero()) assert.NilError(t, err) assert.Equal(t, "alpha", getNamespace(t, cfg)) } @@ -117,7 +132,7 @@ func TestLoadConfigLooksInParentDirectories(t *testing.T) { assert.NilError(t, os.MkdirAll(deepPath, 0755)) assert.NilError(t, os.Chdir(deepPath)) - cfg, err := LoadConfig("") + cfg, err := GetConfig(cmdZero()) assert.NilError(t, err) assert.Equal(t, "alpha", getNamespace(t, cfg)) } @@ -141,7 +156,7 @@ CARDINAL_NAMESPACE="alpha" ` filename := makeTempConfigWithContent(t, content) - cfg, err := LoadConfig(filename) + cfg, err := GetConfig(cmdWithConfig(filename)) assert.NilError(t, err) assert.Equal(t, "alpha", getNamespace(t, cfg)) } @@ -156,7 +171,7 @@ ENV_BETA="beta" ` filename := makeTempConfigWithContent(t, content) - cfg, err := LoadConfig(filename) + cfg, err := GetConfig(cmdWithConfig(filename)) assert.NilError(t, err) assert.Equal(t, cfg.DockerEnv["ENV_ALPHA"], "alpha") assert.Equal(t, cfg.DockerEnv["ENV_BETA"], "beta") @@ -170,7 +185,7 @@ FOO = "bar" filename := makeTempConfigWithContent(t, content) // by default, the root path should match the location of the toml file. wantRootDir, _ := path.Split(filename) - cfg, err := LoadConfig(filename) + cfg, err := GetConfig(cmdWithConfig(filename)) assert.NilError(t, err) assert.Equal(t, wantRootDir, cfg.RootDir) assert.Equal(t, cfg.DockerEnv["FOO"], "bar") @@ -183,14 +198,14 @@ FOO = "bar" ` wantRootDir = "/some/crazy/path" filename = makeTempConfigWithContent(t, content) - cfg, err = LoadConfig(filename) + cfg, err = GetConfig(cmdWithConfig(filename)) assert.NilError(t, err) assert.Equal(t, wantRootDir, cfg.RootDir) assert.Equal(t, "bar", cfg.DockerEnv["FOO"]) } func TestErrorWhenNoConfigFileExists(t *testing.T) { - _, err := LoadConfig("") + _, err := GetConfig(cmdZero()) assert.Check(t, err != nil) } @@ -201,7 +216,7 @@ SOME_INT = 100 SOME_FLOAT = 99.9 ` filename := makeTempConfigWithContent(t, content) - cfg, err := LoadConfig(filename) + cfg, err := GetConfig(cmdWithConfig(filename)) assert.NilError(t, err) assert.Equal(t, "100", cfg.DockerEnv["SOME_INT"]) assert.Equal(t, "99.9", cfg.DockerEnv["SOME_FLOAT"]) @@ -215,7 +230,7 @@ SOME_FLOAT = 99.9 =1000 ` filename := makeTempConfigWithContent(t, invalidContent) - _, err := LoadConfig(filename) + _, err := GetConfig(cmdWithConfig(filename)) assert.Check(t, err != nil) } @@ -254,15 +269,21 @@ DUPLICATE = 200 for _, tc := range testCases { filename := makeTempConfigWithContent(t, tc.toml) - _, err := LoadConfig(filename) + _, err := GetConfig(cmdWithConfig(filename)) assert.Check(t, err != nil, "in %q", tc.name) } } func TestCanParseExampleConfig(t *testing.T) { exampleConfig := "../../example-world.toml" - cfg, err := LoadConfig(exampleConfig) + cfg, err := GetConfig(cmdWithConfig(exampleConfig)) assert.NilError(t, err) assert.Equal(t, "my-world-1", cfg.DockerEnv["CARDINAL_NAMESPACE"]) assert.Equal(t, "world-engine", cfg.DockerEnv["CHAIN_ID"]) } + +func TestConfigFlagCannotBeEmpty(t *testing.T) { + // If you set the config file, it cannot be empty + _, err := GetConfig(cmdWithConfig("")) + assert.Check(t, err != nil) +} diff --git a/common/tea_cmd/docker.go b/common/tea_cmd/docker.go index 9cbc8b3..43e72b9 100644 --- a/common/tea_cmd/docker.go +++ b/common/tea_cmd/docker.go @@ -17,6 +17,8 @@ const ( DockerServiceNakama DockerService = "nakama" DockerServiceNakamaDB DockerService = "nakama-db" DockerServiceRedis DockerService = "redis" + DockerServiceEVM DockerService = "evm" + DockerServiceDA DockerService = "celestia-devnet" ) func dockerCompose(args ...string) error {