From ef82596362fca68ff196318b6d493ab596079d6d Mon Sep 17 00:00:00 2001 From: Scott Sunarto Date: Mon, 20 May 2024 16:52:15 -0700 Subject: [PATCH] feat: cardinal pretty logging, improve goroutine reliability, kill --watch (#64) ### TL;DR ### What changed? - `world cardinal dev` now use pretty logging on latest Cardinal version. This is done by setting `CARDINAL_PRETTY_LOG` to true. - Refactors to goroutine management in `world cardinal start` and `world cardinal dev` to make it more reliable using error group. - Disable printing the usage guide when a command returns an error. - Various string reformatting using lipgloss to make things look pretty. - Prevent exit code 130, 137, 143 from being treated as an error since it's an expected exit code from Docker when it receives termination signal (i.e. Ctrl + C, SIGKILL, SIGTERM, etc) - Kill `world cardinal dev --watch` flag because runner is ugly and buggy af ### How to test? 1. Run `world cardinal dev` and `world cardinal start` and try to break it. This includes running it against various edge cases (i.e. Redis already running, kill redis when cardinal is running, etc) --- cmd/world/cardinal/cardinal.go | 2 +- cmd/world/cardinal/common_flag.go | 7 + cmd/world/cardinal/dev.go | 426 +++++++++++++----------------- cmd/world/cardinal/start.go | 66 ++--- cmd/world/cardinal/util_editor.go | 52 ++++ cmd/world/root/root.go | 30 +++ cmd/world/root/root_test.go | 124 ++++----- common/env.go | 17 ++ common/logger/init.go | 10 +- common/port.go | 21 ++ common/teacmd/docker.go | 19 +- go.mod | 5 +- go.sum | 8 - 13 files changed, 421 insertions(+), 366 deletions(-) create mode 100644 cmd/world/cardinal/common_flag.go create mode 100644 cmd/world/cardinal/util_editor.go create mode 100644 common/env.go create mode 100644 common/port.go diff --git a/cmd/world/cardinal/cardinal.go b/cmd/world/cardinal/cardinal.go index 5fd7f6e..14f3a30 100644 --- a/cmd/world/cardinal/cardinal.go +++ b/cmd/world/cardinal/cardinal.go @@ -34,6 +34,6 @@ var BaseCmd = &cobra.Command{ func init() { // Register subcommands - `world cardinal [subcommand]` BaseCmd.AddCommand(startCmd, devCmd, restartCmd, purgeCmd, stopCmd) - // Add --debug flag + // Add --log-debug flag logger.AddLogFlag(startCmd, devCmd, restartCmd, purgeCmd, stopCmd) } diff --git a/cmd/world/cardinal/common_flag.go b/cmd/world/cardinal/common_flag.go new file mode 100644 index 0000000..960352e --- /dev/null +++ b/cmd/world/cardinal/common_flag.go @@ -0,0 +1,7 @@ +package cardinal + +import "github.com/spf13/cobra" + +func registerEditorFlag(cmd *cobra.Command, defaultEnable bool) { + cmd.Flags().Bool("editor", defaultEnable, "Run Cardinal Editor, useful for prototyping and debugging") +} diff --git a/cmd/world/cardinal/dev.go b/cmd/world/cardinal/dev.go index ed7b2b4..b8a2d18 100644 --- a/cmd/world/cardinal/dev.go +++ b/cmd/world/cardinal/dev.go @@ -1,27 +1,25 @@ package cardinal import ( - "errors" + "context" "fmt" "net" - "net/http" "os" "os/exec" - "os/signal" "path/filepath" "runtime" - "syscall" + "strconv" "time" - "github.com/argus-labs/fresh/runner" "github.com/charmbracelet/lipgloss" "github.com/magefile/mage/sh" "github.com/rotisserie/eris" "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" + "pkg.world.dev/world-cli/common" "pkg.world.dev/world-cli/common/config" "pkg.world.dev/world-cli/common/logger" - "pkg.world.dev/world-cli/common/teacmd" "pkg.world.dev/world-cli/tea/style" ) @@ -33,19 +31,10 @@ const ( cePortStart = 3000 cePortEnd = 4000 - // Cardinal Editor Server Config - ceReadTimeout = 5 * time.Second - - // flagWatch : Flag for hot reload support - flagWatch = "watch" - - // flagNoEditor : Flag for running Cardinal without the editor - flagNoEditor = "no-editor" + // flagPrettyLog Flag that determines whether to run Cardinal with pretty logging (default: true) + flagPrettyLog = "pretty-log" ) -// StopChan is used to signal graceful shutdown -var StopChan = make(chan struct{}) - // devCmd runs Cardinal in development mode // Usage: `world cardinal dev` var devCmd = &cobra.Command{ @@ -53,127 +42,69 @@ var devCmd = &cobra.Command{ Short: "Run Cardinal in development mode", Long: `Run Cardinal in development mode`, RunE: func(cmd *cobra.Command, _ []string) error { - watch, _ := cmd.Flags().GetBool(flagWatch) - noEditor, _ := cmd.Flags().GetBool(flagNoEditor) - logger.SetDebugMode(cmd) - - cfg, err := config.GetConfig(cmd) + editor, err := cmd.Flags().GetBool(flagEditor) if err != nil { return err } - startingMessage := "Running Cardinal in dev mode" - if watch { - startingMessage += " with hot reload support" - } - - fmt.Print(style.CLIHeader("Cardinal", startingMessage), "\n") - fmt.Println(style.BoldText.Render("Press Ctrl+C to stop")) - fmt.Println() - fmt.Printf("Redis: localhost:%s\n", RedisPort) - fmt.Printf("Cardinal: localhost:%s\n", CardinalPort) - - if !noEditor { - // Find an unused port for the Cardinal Editor - cardinalEditorPort, findPortError := findUnusedPort(cePortStart, cePortEnd) - if findPortError == nil { - fmt.Printf("Cardinal Editor: localhost:%d\n", cardinalEditorPort) - } else { - fmt.Println("Cardinal Editor: Failed to find an unused port") - } - - // Run Cardinal Editor - // Cardinal will not blocking the process if it's failed to run - // cePrepChan is channel for blocking process while setup cardinal editor - fmt.Println("Preparing Cardinal Editor...") - cePrepChan := make(chan struct{}) - go func() { - err := runCardinalEditor(cfg.RootDir, cfg.GameDir, cardinalEditorPort, cePrepChan) - if err != nil { - cmdStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("11")) - fmt.Println(cmdStyle.Render("Warning: Failed to run Cardinal Editor")) - logger.Error(eris.Wrap(err, "Failed to run Cardinal Editor")) - - // continue if error - cePrepChan <- struct{}{} - } - }() - // Waiting cardinal editor preparation - <-cePrepChan - } - fmt.Println() - - // Run Redis container - err = runRedis() + prettyLog, err := cmd.Flags().GetBool(flagPrettyLog) if err != nil { return err } - isRedisRunning := false - for !isRedisRunning { - server := fmt.Sprintf("localhost:%s", RedisPort) - timeout := 2 * time.Second //nolint:gomnd - - conn, err := net.DialTimeout("tcp", server, timeout) - if err != nil { - logger.Printf("Failed to connect to Redis server at %s: %s\n", server, err) - continue - } - err = conn.Close() - if err != nil { - continue - } - isRedisRunning = true - } - - // Create a channel to receive termination signals - signalCh := make(chan os.Signal, 1) - signal.Notify(signalCh, os.Interrupt, syscall.SIGTERM) + logger.SetDebugMode(cmd) - // Run Cardinal Preparation - err = runCardinalPrep(cfg.RootDir, cfg.GameDir) + cfg, err := config.GetConfig(cmd) if err != nil { return err } - fmt.Println("Starting Cardinal...") - execCmd, err := runCardinal(watch) - if err != nil { - return err - } + // Print out header + fmt.Println(style.CLIHeader("Cardinal", "")) - // Start a goroutine to check cmd is stopped - go func() { - err := execCmd.Wait() + // Print out service addresses + printServiceAddress("Redis", fmt.Sprintf("localhost:%s", RedisPort)) + printServiceAddress("Cardinal", fmt.Sprintf("localhost:%s", CardinalPort)) + var port int + if editor { + port, err = common.FindUnusedPort(cePortStart, cePortEnd) if err != nil { - logger.Error(eris.Wrap(err, "Cardinal process stopped")) + return eris.Wrap(err, "Failed to find an unused port for Cardinal Editor") } + printServiceAddress("Cardinal Editor", fmt.Sprintf("localhost:%d", port)) + } else { + printServiceAddress("Cardinal Editor", "[disabled]") + } + fmt.Println() - // if process exited, send signal to StopChan - signalCh <- syscall.SIGTERM - }() - - // Start a goroutine to listen for signals - go func() { - <-signalCh - close(StopChan) - }() + // Start redis, cardinal, and cardinal editor + // If any of the services terminates, the entire group will be terminated. + group, ctx := errgroup.WithContext(cmd.Context()) + group.Go(func() error { + if err := startRedis(ctx); err != nil { + return eris.Wrap(err, "Encountered an error with Redis") + } + return eris.Wrap(ErrGracefulExit, "Redis terminated") + }) + group.Go(func() error { + if err := startCardinalDevMode(ctx, cfg, prettyLog); err != nil { + return eris.Wrap(err, "Encountered an error with Cardinal") + } + return eris.Wrap(ErrGracefulExit, "Cardinal terminated") + }) + group.Go(func() error { + if err := startCardinalEditor(ctx, cfg.RootDir, cfg.GameDir, port); err != nil { + return eris.Wrap(err, "Encountered an error with Cardinal Editor") + } + return eris.Wrap(ErrGracefulExit, "Cardinal Editor terminated") + }) - // Wait for stop signal - <-StopChan - err = stopCardinal(execCmd, watch) - if err != nil { + // If any of the group's goroutines is terminated non-gracefully, we want to treat it as an error. + if err := group.Wait(); err != nil && !eris.Is(err, ErrGracefulExit) { return err } - // Cleanup redis - errCleanup := cleanup() - if errCleanup != nil { - return errCleanup - } - return nil - }, } @@ -182,165 +113,182 @@ var devCmd = &cobra.Command{ ///////////////// func init() { - devCmd.Flags().Bool(flagWatch, false, "Dev mode with hot reload support") - devCmd.Flags().Bool(flagNoEditor, false, "Run Cardinal without the editor") + registerEditorFlag(devCmd, true) + devCmd.Flags().Bool(flagPrettyLog, true, "Run Cardinal with pretty logging") } -// runRedis runs Redis in a Docker container -func runRedis() error { - logger.Println("Starting Redis container...") - //nolint:gosec // not applicable - cmd := exec.Command("docker", "run", "-d", "-p", fmt.Sprintf("%s:%s", RedisPort, RedisPort), "--name", - "cardinal-dev-redis", "redis") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr +////////////////////// +// Cardinal Helpers // +////////////////////// - err := cmd.Run() - if err != nil { - logger.Println("Failed to start Redis container. Retrying after cleanup...") - cleanupErr := cleanup() - if cleanupErr != nil { - return err - } - - err := sh.Run("docker", "run", "-d", "-p", fmt.Sprintf("%s:%s", RedisPort, RedisPort), "--name", - "cardinal-dev-redis", "redis") - if err != nil { - if sh.ExitStatus(err) == 125 { //nolint:gomnd - fmt.Println("Maybe redis cardinal docker is still up, run 'world cardinal stop' and try again") - return err - } - return err - } - } - - return nil -} +// startCardinalDevMode runs cardinal in dev mode. +// If watch is true, it uses fresh for hot reload support +// Otherwise, it runs cardinal using `go run .` +func startCardinalDevMode(ctx context.Context, cfg *config.Config, prettyLog bool) error { + fmt.Println("Starting Cardinal...") + fmt.Println(style.BoldText.Render("Press Ctrl+C to stop\n")) -// runCardinalPrep preparation for runs cardinal in dev mode. -// We run cardinal without docker to make it easier to debug and skip the docker image build step -func runCardinalPrep(rootDir string, gameDir string) error { - err := os.Chdir(filepath.Join(rootDir, gameDir)) - if err != nil { - return errors.New("can't find game directory. Are you in the root of a World Engine project") + // Move into the cardinal directory + if err := os.Chdir(filepath.Join(cfg.RootDir, cfg.GameDir)); err != nil { + return eris.New("Unable to find cardinal directory. Are you in the project root?") } - env := map[string]string{ - "REDIS_MODE": "normal", - "CARDINAL_PORT": CardinalPort, - "REDIS_ADDR": fmt.Sprintf("localhost:%s", RedisPort), - "DEPLOY_MODE": "development", - "RUNNER_IGNORED": "assets, tmp, vendor", + // Set world.toml environment variables + if err := common.WithEnv(cfg.DockerEnv); err != nil { + return eris.Wrap(err, "Failed to set world.toml environment variables") } - for key, value := range env { - os.Setenv(key, value) + // Set dev mode environment variables + if err := common.WithEnv( + map[string]string{ + "RUNNER_IGNORED": "assets, tmp, vendor", + "CARDINAL_PRETTY_LOG": strconv.FormatBool(prettyLog), + }, + ); err != nil { + return eris.Wrap(err, "Failed to set dev mode environment variables") } - return nil -} -// runCardinal runs cardinal in dev mode. -// If watch is true, it uses fresh for hot reload support -// Otherwise, it runs cardinal using `go run .` -func runCardinal(watch bool) (*exec.Cmd, error) { - if watch { - // using fresh - go runner.Start() - return &exec.Cmd{}, nil - } + // Create an error group for managing cardinal lifecycle + group, ctx := errgroup.WithContext(ctx) + // Run cardinal cmd := exec.Command("go", "run", ".") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Env = os.Environ() + group.Go(func() error { + if err := cmd.Start(); err != nil { + return eris.Wrap(err, "Failed to start Cardinal") + } - err := cmd.Start() - if err != nil { - return nil, err - } + if err := cmd.Wait(); err != nil { + return err + } + return nil + }) + + // Goroutine to handle termination + // There are two ways that a termination sequence can be triggered: + // 1) The cardinal goroutine returns a non-nil error + // 2) The parent context is canceled for whatever reason. + group.Go(func() error { + <-ctx.Done() + + // No need to do anything if cardinal already exited or is not running + if cmd.ProcessState == nil || cmd.ProcessState.Exited() { + return nil + } - return cmd, nil -} + if runtime.GOOS == "windows" { + // Sending interrupt signal is not supported in Windows + if err := cmd.Process.Kill(); err != nil { + return err + } + } else { + if err := cmd.Process.Signal(os.Interrupt); err != nil { + return err + } + } -// stopCardinal stops the cardinal process -// If watch is true, it stops the fresh process -// Otherwise, it stops the cardinal process -func stopCardinal(cmd *exec.Cmd, watch bool) error { - if watch { - // using fresh - runner.Stop() return nil - } + }) - if cmd.ProcessState == nil || cmd.ProcessState.Exited() { - return nil + if err := group.Wait(); err != nil { + return err } - // stop the cardinal process - if runtime.GOOS == "windows" { - err := cmd.Process.Kill() - if err != nil { - return err + return nil +} + +/////////////////// +// Redis Helpers // +/////////////////// + +// startRedis runs Redis in a Docker container +func startRedis(ctx context.Context) error { + // Create an error group for managing redis lifecycle + group := new(errgroup.Group) + + // Start Redis container + group.Go(func() error { + //nolint:gosec // not applicable + cmd := exec.Command( + "docker", "run", "-d", "-p", fmt.Sprintf("%s:%s", RedisPort, RedisPort), + "--name", "cardinal-dev-redis", "redis", + ) + + // Retry starting Redis container until successful + isDockerRunSuccessful := false + for !isDockerRunSuccessful { + if err := cmd.Run(); err != nil { + logger.Println("Failed to start Redis container. Retrying after cleanup...") + if err := stopRedis(); err != nil { + logger.Println("Failed to stop Redis container") + } + time.Sleep(1 * time.Second) + continue + } + isDockerRunSuccessful = true } - } else { - err := cmd.Process.Signal(os.Interrupt) - if err != nil { + + // Check and wait until Redis is running and is available in the expected port + isRedisHealthy := false + for !isRedisHealthy { + redisAddress := fmt.Sprintf("localhost:%s", RedisPort) + conn, err := net.DialTimeout("tcp", redisAddress, time.Second) + if err != nil { + logger.Printf("Failed to connect to Redis at %s: %s\n", redisAddress, err) + time.Sleep(1 * time.Second) + continue + } + + // Cleanup connection + if err := conn.Close(); err != nil { + continue + } + + isRedisHealthy = true + } + + return nil + }) + + // Goroutine to handle termination + // There are two ways that a termination sequence can be triggered: + // 1) The redis start goroutine returns a non-nil error + // 2) The parent context is canceled for whatever reason. + group.Go(func() error { + <-ctx.Done() + if err := stopRedis(); err != nil { return err } + return nil + }) + + if err := group.Wait(); err != nil { + return err } return nil } -// cleanup stops and removes the Redis and Webdis containers -func cleanup() error { - err := sh.Run("docker", "rm", "-f", "cardinal-dev-redis") - if err != nil { +func stopRedis() error { + logger.Println("Stopping cardinal-dev-redis container") + if err := sh.Run("docker", "rm", "-f", "cardinal-dev-redis"); err != nil { logger.Println("Failed to delete Redis container automatically") logger.Println("Please delete it manually with `docker rm -f cardinal-dev-redis`") return err } - return nil } -// runCardinalEditor runs the Cardinal Editor -func runCardinalEditor(rootDir string, gameDir string, port int, prepChan chan struct{}) error { - cardinalEditorDir := filepath.Join(rootDir, teacmd.TargetEditorDir) +/////////////////// +// Utils Helpers // +/////////////////// - // Setup cardinal editor - err := teacmd.SetupCardinalEditor(rootDir, gameDir) - if err != nil { - prepChan <- struct{}{} - return err - } - - // Serve cardinal editor dir - fs := http.FileServer(http.Dir(cardinalEditorDir)) - http.Handle("/", fs) - - // Create a new HTTP server - server := &http.Server{ - Addr: fmt.Sprintf(":%d", port), - ReadTimeout: ceReadTimeout, - } - - // Preparation done - prepChan <- struct{}{} - - // Start the server - return server.ListenAndServe() -} - -// findUnusedPort finds an unused port in the range [start, end] -func findUnusedPort(start, end int) (int, error) { - for port := start; port <= end; port++ { - address := fmt.Sprintf(":%d", port) - listener, err := net.Listen("tcp", address) - if err == nil { - listener.Close() - return port, nil - } - } - return 0, fmt.Errorf("no available port in the range %d-%d", start, end) +func printServiceAddress(service string, address string) { + serviceStr := lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render(service) + arrowStr := lipgloss.NewStyle().Foreground(lipgloss.Color("7")).Render(" → ") + addressStr := lipgloss.NewStyle().Render(address) + fmt.Println(serviceStr + arrowStr + addressStr) } diff --git a/cmd/world/cardinal/start.go b/cmd/world/cardinal/start.go index 863ba2a..dd0e567 100644 --- a/cmd/world/cardinal/start.go +++ b/cmd/world/cardinal/start.go @@ -4,11 +4,12 @@ import ( "fmt" "strings" - "github.com/charmbracelet/lipgloss" "github.com/rotisserie/eris" "github.com/rs/zerolog" "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" + "pkg.world.dev/world-cli/common" "pkg.world.dev/world-cli/common/config" "pkg.world.dev/world-cli/common/logger" "pkg.world.dev/world-cli/common/teacmd" @@ -41,6 +42,8 @@ var ( zerolog.Disabled.String(), zerolog.TraceLevel.String(), }, ", ") + + ErrGracefulExit = eris.New("Process gracefully exited") ) // startCmd starts your Cardinal game shard stack @@ -100,42 +103,41 @@ This will start the following Docker services and its dependencies: } runEditor, err := cmd.Flags().GetBool(flagEditor) - if err == nil && runEditor { - // Find an unused port for the Cardinal Editor - cardinalEditorPort, findPortError := findUnusedPort(cePortStart, cePortEnd) - if findPortError == nil { - cmdBlue := lipgloss.NewStyle().Foreground(lipgloss.Color("6")) - fmt.Println(cmdBlue.Render("Preparing Cardinal Editor...")) - fmt.Println(cmdBlue.Render(fmt.Sprint("Cardinal Editor will be run on localhost:", cardinalEditorPort))) - cePrepChan := make(chan struct{}) - go func() { - err := runCardinalEditor(cfg.RootDir, cfg.GameDir, cardinalEditorPort, cePrepChan) - if err != nil { - cmdStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("11")) - fmt.Println(cmdStyle.Render("Warning: Failed to run Cardinal Editor")) - logger.Error(eris.Wrap(err, "Failed to run Cardinal Editor")) - - // continue if error - cePrepChan <- struct{}{} - } - }() - // Waiting cardinal editor preparation - <-cePrepChan - } else { - // just log the error if the editor fails to run - logger.Error(eris.Wrap(findPortError, "Failed to find an unused port for Cardinal Editor")) - } - } else { - // just log the error if the editor fails to run - logger.Error(eris.Wrap(err, "Failed to run Cardinal Editor")) + if err != nil { + return err } fmt.Println("Starting Cardinal game shard...") fmt.Println("This may take a few minutes to rebuild the Docker images.") fmt.Println("Use `world cardinal dev` to run Cardinal faster/easier in development mode.") - err = teacmd.DockerStartAll(cfg) - if err != nil { + group, ctx := errgroup.WithContext(cmd.Context()) + + // Start the World Engine stack + group.Go(func() error { + if err := teacmd.DockerStartAll(cfg); err != nil { + return eris.Wrap(err, "Encountered an error with Docker") + } + return eris.Wrap(ErrGracefulExit, "Stack terminated") + }) + + // Start Cardinal Editor is flag is set to true + if runEditor { + editorPort, err := common.FindUnusedPort(cePortStart, cePortEnd) + if err != nil { + return eris.Wrap(err, "Failed to find an unused port for Cardinal Editor") + } + + group.Go(func() error { + if err := startCardinalEditor(ctx, cfg.RootDir, cfg.GameDir, editorPort); err != nil { + return eris.Wrap(err, "Encountered an error with Cardinal Editor") + } + return eris.Wrap(ErrGracefulExit, "Cardinal Editor terminated") + }) + } + + // If any of the group's goroutines is terminated non-gracefully, we want to treat it as an error. + if err := group.Wait(); err != nil && !eris.Is(err, ErrGracefulExit) { return err } @@ -144,10 +146,10 @@ This will start the following Docker services and its dependencies: } func init() { + registerEditorFlag(startCmd, true) startCmd.Flags().Bool(flagBuild, true, "Rebuild Docker images before starting") startCmd.Flags().Bool(flagDetach, false, "Run in detached mode") startCmd.Flags().String(flagLogLevel, "", "Set the log level") - startCmd.Flags().Bool(flagEditor, false, "Start cardinal with cardinal editor") } // replaceBoolWithFlag overwrites the contents of vale with the contents of the given flag. If the flag diff --git a/cmd/world/cardinal/util_editor.go b/cmd/world/cardinal/util_editor.go new file mode 100644 index 0000000..b5e6bd9 --- /dev/null +++ b/cmd/world/cardinal/util_editor.go @@ -0,0 +1,52 @@ +package cardinal + +import ( + "context" + "fmt" + "net/http" + "path/filepath" + "time" + + "github.com/rotisserie/eris" + "golang.org/x/sync/errgroup" + + "pkg.world.dev/world-cli/common/teacmd" +) + +const ceReadTimeout = 5 * time.Second + +// startCardinalEditor runs the Cardinal Editor +func startCardinalEditor(ctx context.Context, rootDir string, gameDir string, port int) error { + if err := teacmd.SetupCardinalEditor(rootDir, gameDir); err != nil { + return err + } + + // Create a new HTTP server + fs := http.FileServer(http.Dir(filepath.Join(rootDir, teacmd.TargetEditorDir))) + http.Handle("/", fs) + server := &http.Server{ + Addr: fmt.Sprintf(":%d", port), + ReadTimeout: ceReadTimeout, + } + + group, ctx := errgroup.WithContext(ctx) + group.Go(func() error { + if err := server.ListenAndServe(); err != nil && !eris.Is(err, http.ErrServerClosed) { + return eris.Wrap(err, "Cardinal Editor server encountered an error") + } + return nil + }) + group.Go(func() error { + <-ctx.Done() + if err := server.Shutdown(ctx); err != nil { + return eris.Wrap(err, "Failed to gracefully shutdown server") + } + return nil + }) + + if err := group.Wait(); err != nil { + return err + } + + return nil +} diff --git a/cmd/world/root/root.go b/cmd/world/root/root.go index 7fa124e..d195714 100644 --- a/cmd/world/root/root.go +++ b/cmd/world/root/root.go @@ -8,6 +8,8 @@ import ( "io" "net/http" "os" + "os/signal" + "syscall" "time" "github.com/charmbracelet/lipgloss" @@ -51,6 +53,12 @@ func init() { // Enable case-insensitive commands cobra.EnableCaseInsensitive = true + // Disable printing usage help text when command returns a non-nil error + rootCmd.SilenceUsage = true + + // Injects a context that is canceled when a sigterm signal is received + rootCmd.SetContext(contextWithSigterm(context.Background())) + // Register groups rootCmd.AddGroup(&cobra.Group{ID: "starter", Title: "Getting Started:"}) rootCmd.AddGroup(&cobra.Group{ID: "core", Title: "Tools:"}) @@ -139,3 +147,25 @@ func Execute() { // print log stack logger.PrintLogs() } + +// contextWithSigterm provides a context that automatically terminates when either the parent context is canceled or +// when a termination signal is received +func contextWithSigterm(ctx context.Context) context.Context { + ctx, cancel := context.WithCancel(ctx) + textStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("3")) + + go func() { + defer cancel() + + signalCh := make(chan os.Signal, 1) + signal.Notify(signalCh, os.Interrupt, syscall.SIGTERM) + + select { + case <-signalCh: + fmt.Println(textStyle.Render("Interrupt signal received. Terminating...")) + case <-ctx.Done(): + fmt.Println(textStyle.Render("Cancellation signal received. Terminating...")) + } + }() + return ctx +} diff --git a/cmd/world/root/root_test.go b/cmd/world/root/root_test.go index 7891ec3..e1fa12b 100644 --- a/cmd/world/root/root_test.go +++ b/cmd/world/root/root_test.go @@ -2,10 +2,10 @@ package root import ( "bytes" - "encoding/json" + "context" "errors" "fmt" - "net/http" + "net" "os" "strings" "testing" @@ -13,32 +13,8 @@ import ( "github.com/spf13/cobra" "gotest.tools/v3/assert" - - "pkg.world.dev/world-cli/cmd/world/cardinal" ) -type healthResponse struct { - StatusCode int - IsServerRunning bool - IsGameLoopRunning bool -} - -func getHealthCheck() (*healthResponse, error) { - var healtCheck healthResponse - - resp, err := http.Get("http://127.0.0.1:4040/health") - if err != nil { - return nil, err - } - err = json.NewDecoder(resp.Body).Decode(&healtCheck) - if err != nil { - return nil, err - } - - healtCheck.StatusCode = resp.StatusCode - return &healtCheck, nil -} - // outputFromCmd runs the rootCmd with the given cmd arguments and returns the output of the command along with // any errors. func outputFromCmd(cobra *cobra.Command, cmd string) ([]string, error) { @@ -149,7 +125,7 @@ func TestCreateStartStopRestartPurge(t *testing.T) { assert.NilError(t, err) // Start cardinal - rootCmd.SetArgs([]string{"cardinal", "start", "--build", "--detach"}) + rootCmd.SetArgs([]string{"cardinal", "start", "--build", "--detach", "--editor=false"}) err = rootCmd.Execute() assert.NilError(t, err) @@ -160,34 +136,24 @@ func TestCreateStartStopRestartPurge(t *testing.T) { assert.NilError(t, err) }() - // Check cardinal health - resp, err := getHealthCheck() - assert.NilError(t, err) - assert.Equal(t, resp.StatusCode, 200) - assert.Assert(t, resp.IsServerRunning) - assert.Assert(t, resp.IsGameLoopRunning) + // Check and wait until cardinal is healthy + assert.Assert(t, cardinalIsUp(t), "Cardinal is not running") // Restart cardinal rootCmd.SetArgs([]string{"cardinal", "restart", "--detach"}) err = rootCmd.Execute() assert.NilError(t, err) - // Check cardinal health after restart - resp, err = getHealthCheck() - assert.NilError(t, err) - assert.Equal(t, resp.StatusCode, 200) - assert.Assert(t, resp.IsServerRunning) - assert.Assert(t, resp.IsGameLoopRunning) + // Check and wait until cardinal is healthy + assert.Assert(t, cardinalIsUp(t), "Cardinal is not running") // Stop cardinal rootCmd.SetArgs([]string{"cardinal", "stop"}) err = rootCmd.Execute() assert.NilError(t, err) - // Check cardinal health (expected error) - _, err = getHealthCheck() - assert.Error(t, err, - "Get \"http://127.0.0.1:4040/health\": dial tcp 127.0.0.1:4040: connect: connection refused") + // Check and wait until cardinal shutdowns + assert.Assert(t, cardinalIsDown(t), "Cardinal is not successfully shutdown") } func TestDev(t *testing.T) { @@ -214,45 +180,21 @@ func TestDev(t *testing.T) { assert.NilError(t, err) // Start cardinal dev - rootCmd.SetArgs([]string{"cardinal", "dev"}) + ctx, cancel := context.WithCancel(context.Background()) + rootCmd.SetArgs([]string{"cardinal", "dev", "--editor=false"}) go func() { - err := rootCmd.Execute() + err := rootCmd.ExecuteContext(ctx) assert.NilError(t, err) }() - // Check cardinal health for 300 second, waiting to download dependencies and building the apps - isServerRunning := false - isGameLoopRunning := false - timeout := time.Now().Add(300 * time.Second) - for !(isServerRunning && isGameLoopRunning) && time.Now().Before(timeout) { - resp, err := getHealthCheck() - if err != nil { - time.Sleep(1 * time.Second) - continue - } - assert.Equal(t, resp.StatusCode, 200) - isServerRunning = resp.IsServerRunning - isGameLoopRunning = resp.IsGameLoopRunning - } - assert.Assert(t, isServerRunning) - assert.Assert(t, isGameLoopRunning) + // Check and wait until cardinal is healthy + assert.Assert(t, cardinalIsUp(t), "Cardinal is not running") // Shutdown the program - cardinal.StopChan <- struct{}{} - - // Check cardinal health (expected error), trying for 10 times - count := 0 - for count < 10 { - _, err = getHealthCheck() - if err != nil { - break - } - time.Sleep(1 * time.Second) - count++ - } + cancel() - assert.Error(t, err, - "Get \"http://127.0.0.1:4040/health\": dial tcp 127.0.0.1:4040: connect: connection refused") + // Check and wait until cardinal shutdowns + assert.Assert(t, cardinalIsDown(t), "Cardinal is not successfully shutdown") } func TestCheckLatestVersion(t *testing.T) { @@ -268,3 +210,35 @@ func TestCheckLatestVersion(t *testing.T) { assert.Error(t, err, "error parsing current version: Malformed version: wrong format") }) } + +func cardinalIsUp(t *testing.T) bool { + up := false + for i := 0; i < 10; i++ { + conn, err := net.DialTimeout("tcp", "localhost:4040", time.Second) + if err != nil { + time.Sleep(time.Second) + t.Log("Failed to connect to Cardinal, retrying...") + continue + } + _ = conn.Close() + up = true + break + } + return up +} + +func cardinalIsDown(t *testing.T) bool { + down := false + for i := 0; i < 10; i++ { + conn, err := net.DialTimeout("tcp", "localhost:4040", time.Second) + if err != nil { + down = true + break + } + _ = conn.Close() + time.Sleep(time.Second) + t.Log("Cardinal is still running, retrying...") + continue + } + return down +} diff --git a/common/env.go b/common/env.go new file mode 100644 index 0000000..cf9e02a --- /dev/null +++ b/common/env.go @@ -0,0 +1,17 @@ +package common + +import ( + "os" + + "github.com/rotisserie/eris" +) + +// WithEnv sets the environment variables from the given map. +func WithEnv(env map[string]string) error { + for key, value := range env { + if err := os.Setenv(key, value); err != nil { + return eris.Wrap(err, "Failed to set env var") + } + } + return nil +} diff --git a/common/logger/init.go b/common/logger/init.go index b00f81d..83e9b9c 100644 --- a/common/logger/init.go +++ b/common/logger/init.go @@ -15,7 +15,7 @@ const ( NoColor = true UseCaller = false // for developer, if you want to expose line of code of caller - flagDebug = "debug" + flagDebug = "log-debug" ) var ( @@ -63,17 +63,17 @@ func PrintLogs() { } // SetDebugMode Allow particular logger/message to be printed -// This function will extract flag --debug from command +// This function will extract flag --log-debug from command func SetDebugMode(cmd *cobra.Command) { - val, err := cmd.Flags().GetBool("debug") + val, err := cmd.Flags().GetBool(flagDebug) if err == nil { DebugMode = val } } -// AddLogFlag set flag --debug +// AddLogFlag set flag --log-debug func AddLogFlag(cmd ...*cobra.Command) { for _, c := range cmd { - c.Flags().Bool(flagDebug, false, "Run in debug mode") + c.Flags().Bool(flagDebug, false, "Enable World CLI debug logs") } } diff --git a/common/port.go b/common/port.go new file mode 100644 index 0000000..40e97b7 --- /dev/null +++ b/common/port.go @@ -0,0 +1,21 @@ +package common + +import ( + "fmt" + "net" +) + +// FindUnusedPort finds an unused port in the range [start, end] for Cardinal Editor +func FindUnusedPort(start int, end int) (int, error) { + for port := start; port <= end; port++ { + address := fmt.Sprintf(":%d", port) + listener, err := net.Listen("tcp", address) + if err == nil { + if err := listener.Close(); err != nil { + return 0, err + } + return port, nil + } + } + return 0, fmt.Errorf("no available port in the range %d-%d", start, end) +} diff --git a/common/teacmd/docker.go b/common/teacmd/docker.go index 28b490c..07d5d2c 100644 --- a/common/teacmd/docker.go +++ b/common/teacmd/docker.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path" + "slices" "strings" "github.com/magefile/mage/sh" @@ -46,8 +47,22 @@ func dockerComposeWithCfg(cfg *config.Config, args ...string) error { } cmd.Env = env - return cmd.Run() - // return sh.RunWith(cfg.DockerEnv, "docker", args...) + if err := cmd.Run(); err != nil { + var exitCode *exec.ExitError + if errors.As(err, &exitCode) { + // Ignore exit codes 130, 137, and 143 as they are expected to be returned on termination. + // Exit code 130: Container terminated by Ctrl+C + // Exit code 137: Container terminated by SIGKILL + // Exit code 143: Container terminated by SIGTERM + expectedExitCodes := []int{130, 137, 143} + if slices.Contains(expectedExitCodes, exitCode.ExitCode()) { + return nil + } + return err + } + } + + return nil } // DockerStart starts a given docker container by name. diff --git a/go.mod b/go.mod index b7a5149..ee5020c 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module pkg.world.dev/world-cli go 1.21.1 require ( - github.com/argus-labs/fresh v0.0.2 github.com/charmbracelet/bubbles v0.16.1 github.com/charmbracelet/bubbletea v0.24.2 github.com/denisbrodbeck/machineid v1.0.1 @@ -25,9 +24,7 @@ require ( require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/google/go-cmp v0.5.9 // indirect - github.com/howeyc/fsnotify v0.9.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/pilu/config v0.0.0-20131214182432-3eb99e6c0b9a // indirect ) require ( @@ -45,7 +42,7 @@ require ( github.com/muesli/termenv v0.15.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - golang.org/x/sync v0.1.0 // indirect + golang.org/x/sync v0.1.0 golang.org/x/sys v0.13.0 // indirect golang.org/x/term v0.6.0 // indirect golang.org/x/text v0.8.0 // indirect diff --git a/go.sum b/go.sum index 57759bd..23cb88a 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,4 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/argus-labs/fresh v0.0.2 h1:rjmhnm7JkkN6FCzxEbSrIJoOsG0TkxQVBtVbOsbQVPM= -github.com/argus-labs/fresh v0.0.2/go.mod h1:0JyLyBC5gajmcQ8NK6qhf/GNy36I7PU/8XzHQVzDcWI= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -34,8 +32,6 @@ github.com/guumaster/logsymbols v0.3.1 h1:bnCE484dAQFvMWt2EfZAzF1oCgu8yo/Vp1QGQ0 github.com/guumaster/logsymbols v0.3.1/go.mod h1:1M5/1js2Z7Yo8DRB3QrPURwqsXeOfgsJv1Utjookknw= github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/howeyc/fsnotify v0.9.0 h1:0gtV5JmOKH4A8SsFxG2BczSeXWWPvcMT0euZt5gDAxY= -github.com/howeyc/fsnotify v0.9.0/go.mod h1:41HzSPxBGeFRQKEEwgh49TRw/nKBsYZ2cF1OzPjSJsA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -65,10 +61,6 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.0.5 h1:ipoSadvV8oGUjnUbMub59IDPPwfxF694nG/jwbMiyQg= github.com/pelletier/go-toml/v2 v2.0.5/go.mod h1:OMHamSCAODeSsVrwwvcJOaoN0LIUIaFVNZzmWyNfXas= -github.com/pilu/config v0.0.0-20131214182432-3eb99e6c0b9a h1:Tg4E4cXPZSZyd3H1tJlYo6ZreXV0ZJvE/lorNqyw1AU= -github.com/pilu/config v0.0.0-20131214182432-3eb99e6c0b9a/go.mod h1:9Or9aIl95Kp43zONcHd5tLZGKXb9iLx0pZjau0uJ5zg= -github.com/pilu/miniassert v0.0.0-20140522125902-bee63581261a h1:U8Xgy85P2npR1jqpNF4q9rLSt6kzrOrWubkepc3lWbE= -github.com/pilu/miniassert v0.0.0-20140522125902-bee63581261a/go.mod h1:QKd9TvGj31l6g+MV2PqthhK68d5ZPv/Q2PvtsS0mM3I= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=