From 0308c8f997ab83d5f9c896f667025571da5042fc Mon Sep 17 00:00:00 2001 From: zulkhair Date: Tue, 6 Aug 2024 02:04:03 +0700 Subject: [PATCH] change docker compose using docker api sdk --- cmd/world/cardinal/dev.go | 14 +- cmd/world/cardinal/purge.go | 20 +- cmd/world/cardinal/restart.go | 19 +- cmd/world/cardinal/start.go | 13 +- cmd/world/cardinal/stop.go | 20 +- cmd/world/evm/start.go | 116 ++++--- cmd/world/evm/stop.go | 20 +- cmd/world/evm/util.go | 7 - cmd/world/root/login.go | 181 ---------- cmd/world/root/root.go | 3 +- cmd/world/root/root_test.go | 157 ++++----- common/config/config.go | 1 + common/docker/client.go | 189 +++++++++++ common/docker/client_container.go | 167 ++++++++++ common/docker/client_image.go | 385 ++++++++++++++++++++++ common/docker/client_network.go | 32 ++ common/docker/client_test.go | 233 +++++++++++++ common/docker/client_util.go | 53 +++ common/docker/client_volume.go | 64 ++++ common/docker/service/cardinal.Dockerfile | 61 ++++ common/docker/service/cardinal.go | 86 +++++ common/docker/service/celestia.go | 44 +++ common/docker/service/evm.go | 64 ++++ common/docker/service/nakama.go | 78 +++++ common/docker/service/nakamadb.go | 54 +++ common/docker/service/redis.go | 50 +++ common/docker/service/service.go | 71 ++++ go.mod | 44 ++- go.sum | 143 +++++++- makefiles/lint.mk | 4 +- tea/component/spinner/spinner.go | 61 ++++ 31 files changed, 2068 insertions(+), 386 deletions(-) delete mode 100644 cmd/world/evm/util.go delete mode 100644 cmd/world/root/login.go create mode 100644 common/docker/client.go create mode 100644 common/docker/client_container.go create mode 100644 common/docker/client_image.go create mode 100644 common/docker/client_network.go create mode 100644 common/docker/client_test.go create mode 100644 common/docker/client_util.go create mode 100644 common/docker/client_volume.go create mode 100644 common/docker/service/cardinal.Dockerfile create mode 100644 common/docker/service/cardinal.go create mode 100644 common/docker/service/celestia.go create mode 100644 common/docker/service/evm.go create mode 100644 common/docker/service/nakama.go create mode 100644 common/docker/service/nakamadb.go create mode 100644 common/docker/service/redis.go create mode 100644 common/docker/service/service.go create mode 100644 tea/component/spinner/spinner.go diff --git a/cmd/world/cardinal/dev.go b/cmd/world/cardinal/dev.go index 1e04da6..91a9f0c 100644 --- a/cmd/world/cardinal/dev.go +++ b/cmd/world/cardinal/dev.go @@ -18,8 +18,9 @@ import ( "pkg.world.dev/world-cli/common" "pkg.world.dev/world-cli/common/config" + "pkg.world.dev/world-cli/common/docker" + "pkg.world.dev/world-cli/common/docker/service" "pkg.world.dev/world-cli/common/logger" - "pkg.world.dev/world-cli/common/teacmd" "pkg.world.dev/world-cli/tea/style" ) @@ -228,10 +229,17 @@ func startRedis(ctx context.Context, cfg *config.Config) error { // Create an error group for managing redis lifecycle group := new(errgroup.Group) + // Create docker client + dockerClient, err := docker.NewClient(cfg) + if err != nil { + return err + } + defer dockerClient.Close() + // Start Redis container group.Go(func() error { cfg.Detach = true - if err := teacmd.DockerStart(cfg, []teacmd.DockerService{teacmd.DockerServiceRedis}); err != nil { + if err := dockerClient.Start(ctx, cfg, service.Redis); err != nil { return eris.Wrap(err, "Encountered an error with Redis") } return nil @@ -243,7 +251,7 @@ func startRedis(ctx context.Context, cfg *config.Config) error { // 2) The parent context is canceled for whatever reason. group.Go(func() error { <-ctx.Done() - if err := teacmd.DockerStop([]teacmd.DockerService{teacmd.DockerServiceRedis}); err != nil { + if err := dockerClient.Stop(cfg, service.Redis); err != nil { return err } return nil diff --git a/cmd/world/cardinal/purge.go b/cmd/world/cardinal/purge.go index 46e2daf..228651a 100644 --- a/cmd/world/cardinal/purge.go +++ b/cmd/world/cardinal/purge.go @@ -5,7 +5,9 @@ import ( "github.com/spf13/cobra" - "pkg.world.dev/world-cli/common/teacmd" + "pkg.world.dev/world-cli/common/config" + "pkg.world.dev/world-cli/common/docker" + "pkg.world.dev/world-cli/common/docker/service" ) ///////////////// @@ -19,8 +21,20 @@ var purgeCmd = &cobra.Command{ Short: "Stop and reset the state of your Cardinal game shard", Long: `Stop and reset the state of your Cardinal game shard. This command stop all Docker services and remove all Docker volumes.`, - RunE: func(_ *cobra.Command, _ []string) error { - err := teacmd.DockerPurge() + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := config.GetConfig(cmd) + if err != nil { + return err + } + + // Create a new Docker client + dockerClient, err := docker.NewClient(cfg) + if err != nil { + return err + } + defer dockerClient.Close() + + err = dockerClient.Purge(cfg, service.Nakama, service.Cardinal, service.NakamaDB, service.Redis) if err != nil { return err } diff --git a/cmd/world/cardinal/restart.go b/cmd/world/cardinal/restart.go index 84488c5..7d829a9 100644 --- a/cmd/world/cardinal/restart.go +++ b/cmd/world/cardinal/restart.go @@ -4,7 +4,8 @@ import ( "github.com/spf13/cobra" "pkg.world.dev/world-cli/common/config" - "pkg.world.dev/world-cli/common/teacmd" + "pkg.world.dev/world-cli/common/docker" + "pkg.world.dev/world-cli/common/docker/service" ) // restartCmd restarts your Cardinal game shard stack @@ -31,18 +32,14 @@ This will restart the following Docker services: return err } - if cfg.Debug { - err = teacmd.DockerRestart(cfg, []teacmd.DockerService{ - teacmd.DockerServiceCardinalDebug, - teacmd.DockerServiceNakama, - }) - } else { - err = teacmd.DockerRestart(cfg, []teacmd.DockerService{ - teacmd.DockerServiceCardinal, - teacmd.DockerServiceNakama, - }) + // Create docker client + dockerClient, err := docker.NewClient(cfg) + if err != nil { + return err } + defer dockerClient.Close() + err = dockerClient.Restart(cmd.Context(), cfg, service.Cardinal, service.Nakama) if err != nil { return err } diff --git a/cmd/world/cardinal/start.go b/cmd/world/cardinal/start.go index 8ed5085..838e315 100644 --- a/cmd/world/cardinal/start.go +++ b/cmd/world/cardinal/start.go @@ -11,7 +11,8 @@ import ( "pkg.world.dev/world-cli/common" "pkg.world.dev/world-cli/common/config" - "pkg.world.dev/world-cli/common/teacmd" + "pkg.world.dev/world-cli/common/docker" + "pkg.world.dev/world-cli/common/docker/service" ) ///////////////// @@ -110,9 +111,17 @@ This will start the following Docker services and its dependencies: group, ctx := errgroup.WithContext(cmd.Context()) + // Create docker client + dockerClient, err := docker.NewClient(cfg) + if err != nil { + return err + } + defer dockerClient.Close() + // Start the World Engine stack group.Go(func() error { - if err := teacmd.DockerStartAll(cfg); err != nil { + if err := dockerClient.Start(ctx, cfg, service.NakamaDB, + service.Redis, service.Cardinal, service.Nakama); err != nil { return eris.Wrap(err, "Encountered an error with Docker") } return eris.Wrap(ErrGracefulExit, "Stack terminated") diff --git a/cmd/world/cardinal/stop.go b/cmd/world/cardinal/stop.go index c10a96d..f1de6b0 100644 --- a/cmd/world/cardinal/stop.go +++ b/cmd/world/cardinal/stop.go @@ -5,7 +5,9 @@ import ( "github.com/spf13/cobra" - "pkg.world.dev/world-cli/common/teacmd" + "pkg.world.dev/world-cli/common/config" + "pkg.world.dev/world-cli/common/docker" + "pkg.world.dev/world-cli/common/docker/service" ) ///////////////// @@ -23,8 +25,20 @@ This will stop the following Docker services: - Cardinal (Game shard) - Nakama (Relay) + DB - Redis (Cardinal dependency)`, - RunE: func(_ *cobra.Command, _ []string) error { - err := teacmd.DockerStopAll() + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := config.GetConfig(cmd) + if err != nil { + return err + } + + // Create docker client + dockerClient, err := docker.NewClient(cfg) + if err != nil { + return err + } + defer dockerClient.Close() + + err = dockerClient.Stop(cfg, service.Nakama, service.Cardinal, service.NakamaDB, service.Redis) if err != nil { return err } diff --git a/cmd/world/evm/start.go b/cmd/world/evm/start.go index e530592..f209401 100644 --- a/cmd/world/evm/start.go +++ b/cmd/world/evm/start.go @@ -1,27 +1,21 @@ package evm import ( - "bytes" + "context" "errors" "fmt" "net" - "os/exec" - "strings" - "time" + "github.com/rotisserie/eris" "github.com/spf13/cobra" "pkg.world.dev/world-cli/common/config" + "pkg.world.dev/world-cli/common/docker" + "pkg.world.dev/world-cli/common/docker/service" "pkg.world.dev/world-cli/common/logger" "pkg.world.dev/world-cli/common/teacmd" ) -var ( - // Docker compose seems to replace the hyphen with an underscore. This could be properly fixed by removing the hyphen - // from celestia-devnet, or by investigating the docker compose documentation. - daContainer = strings.ReplaceAll(string(daService), "-", "_") -) - var startCmd = &cobra.Command{ Use: "start", Short: "Start the EVM base shard. Use --da-auth-token to pass in an auth token directly.", @@ -32,7 +26,14 @@ var startCmd = &cobra.Command{ return err } - if err = validateDALayer(cmd, cfg); err != nil { + // Create docker client + dockerClient, err := docker.NewClient(cfg) + if err != nil { + return err + } + defer dockerClient.Close() + + if err = validateDALayer(cmd, cfg, dockerClient); err != nil { return err } @@ -49,10 +50,18 @@ var startCmd = &cobra.Command{ cfg.Detach = false cfg.Timeout = 0 - err = teacmd.DockerStart(cfg, services(teacmd.DockerServiceEVM)) + err = dockerClient.Start(cmd.Context(), cfg, service.EVM) if err != nil { return fmt.Errorf("error starting %s docker container: %w", teacmd.DockerServiceEVM, err) } + + // Stop the DA service if it was started in dev mode + if cfg.DevDA { + err = dockerClient.Stop(cfg, service.CelestiaDevNet) + if err != nil { + return eris.Wrap(err, "Failed to stop DA service") + } + } return nil }, } @@ -65,22 +74,18 @@ func init() { // 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 { +func validateDevDALayer(ctx context.Context, cfg *config.Config, dockerClient *docker.Client) error { cfg.Build = true cfg.Debug = false cfg.Detach = true cfg.Timeout = -1 logger.Println("starting DA docker service for dev mode...") - if err := teacmd.DockerStart(cfg, services(daService)); err != nil { + if err := dockerClient.Start(ctx, cfg, service.CelestiaDevNet); err != nil { return fmt.Errorf("error starting %s docker container: %w", daService, err) } - - if err := blockUntilContainerIsRunning(daContainer, 10*time.Second); err != nil { //nolint:gomnd - return err - } logger.Println("started DA service...") - daToken, err := getDAToken() + daToken, err := getDAToken(ctx, cfg, dockerClient) if err != nil { return err } @@ -121,58 +126,49 @@ func validateProdDALayer(cfg *config.Config) error { return nil } -func validateDALayer(cmd *cobra.Command, cfg *config.Config) error { +func validateDALayer(cmd *cobra.Command, cfg *config.Config, dockerClient *docker.Client) error { devDA, err := cmd.Flags().GetBool(FlagUseDevDA) if err != nil { return err } if devDA { - return validateDevDALayer(cfg) + cfg.DevDA = true + return validateDevDALayer(cmd.Context(), cfg, dockerClient) } return validateProdDALayer(cfg) } -func getDAToken() (string, 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++ { - logger.Println("attempting to get DA token...") - - cmd := exec.Command(cmdParts[0], cmdParts[1:]...) //nolint:gosec // not applicable - output, err := cmd.CombinedOutput() - if err != nil { - logger.Println("failed to get da token") - logger.Printf("%d/%d retrying...\n", retry+1, maxRetries) - time.Sleep(2 * time.Second) //nolint:gomnd - continue - } +func getDAToken(ctx context.Context, cfg *config.Config, dockerClient *docker.Client) (string, error) { + fmt.Println("Getting DA token") - 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 "", errors.New("got empty DA token") - } - return string(output), nil + containerName := service.CelestiaDevNet(cfg) + + _, err := dockerClient.Exec(ctx, containerName.Name, + []string{ + "mkdir", + "-p", + "/home/celestia/bridge/keys", + }) + if err != nil { + return "", eris.Wrap(err, "Failed to create keys directory") } - return "", errors.New("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() //nolint:gosec // not applicable - if err == nil && string(output) == runningOutput { - return nil - } - time.Sleep(250 * time.Millisecond) //nolint:gomnd + token, err := dockerClient.Exec(ctx, containerName.Name, + []string{ + "celestia", + "bridge", + "--node.store", + "/home/celestia/bridge/", + "auth", + "admin", + }) + + if err != nil { + return "", eris.Wrapf(err, "Failed to get DA token") + } + + if token == "" { + return "", errors.New("got empty DA token") } - return fmt.Errorf("timeout while waiting for %q to start", targetContainer) + return token, nil } diff --git a/cmd/world/evm/stop.go b/cmd/world/evm/stop.go index badf018..3afb571 100644 --- a/cmd/world/evm/stop.go +++ b/cmd/world/evm/stop.go @@ -5,15 +5,29 @@ import ( "github.com/spf13/cobra" - "pkg.world.dev/world-cli/common/teacmd" + "pkg.world.dev/world-cli/common/config" + "pkg.world.dev/world-cli/common/docker" + "pkg.world.dev/world-cli/common/docker/service" ) var stopCmd = &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(_ *cobra.Command, _ []string) error { - err := teacmd.DockerStop(services(teacmd.DockerServiceEVM, teacmd.DockerServiceDA)) + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := config.GetConfig(cmd) + if err != nil { + return err + } + + // Create docker client + dockerClient, err := docker.NewClient(cfg) + if err != nil { + return err + } + defer dockerClient.Close() + + err = dockerClient.Stop(cfg, service.EVM, service.CelestiaDevNet) if err != nil { return err } diff --git a/cmd/world/evm/util.go b/cmd/world/evm/util.go deleted file mode 100644 index 94cd336..0000000 --- a/cmd/world/evm/util.go +++ /dev/null @@ -1,7 +0,0 @@ -package evm - -import "pkg.world.dev/world-cli/common/teacmd" - -func services(s ...teacmd.DockerService) []teacmd.DockerService { - return s -} diff --git a/cmd/world/root/login.go b/cmd/world/root/login.go deleted file mode 100644 index 2125080..0000000 --- a/cmd/world/root/login.go +++ /dev/null @@ -1,181 +0,0 @@ -package root - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "os/user" - "strconv" - "time" - - "github.com/google/uuid" - "github.com/pkg/errors" - "github.com/rotisserie/eris" - "github.com/spf13/cobra" - - "pkg.world.dev/world-cli/common/globalconfig" - "pkg.world.dev/world-cli/common/logger" - "pkg.world.dev/world-cli/common/login" - "pkg.world.dev/world-cli/tea/component/program" - "pkg.world.dev/world-cli/tea/style" -) - -var ( - // token is the credential used to authenticate with the World Forge Service - token string - - // world forge base URL - worldForgeBaseURL = "http://localhost:3000" - - defaultRetryAfterSeconds = 3 -) - -// loginCmd logs into the World Forge Service -func getLoginCmd() *cobra.Command { - loginCmd := &cobra.Command{ - Use: "login", - Short: "Authenticate using an access token", - RunE: func(cmd *cobra.Command, _ []string) error { - err := loginOnBrowser(cmd.Context()) - if err != nil { - return eris.Wrap(err, "failed to login") - } - - return nil - }, - } - - return loginCmd -} - -func loginOnBrowser(ctx context.Context) error { - encryption, err := login.NewEncryption() - if err != nil { - logger.Error("Failed to create login encryption", err) - return err - } - - encodedPubKey := encryption.EncodedPublicKey() - sessionID := uuid.NewString() - tokenName := generateTokenNameWithFallback() - - loginURL := fmt.Sprintf("%s/cli/login?session_id=%s&token=%s&pub_key=%s", - worldForgeBaseURL, sessionID, tokenName, encodedPubKey) - - loginMessage := "In case the browser didn't open, please open the following link in your browser" - fmt.Print(style.CLIHeader("World Forge", style.DoubleRightIcon.Render(loginMessage)), "\n") - fmt.Printf("%s\n\n", loginURL) - if err := login.RunOpenCmd(ctx, loginURL); err != nil { - logger.Error("Failed to open browser", err) - return err - } - - // Wait for the token to be generated - if err := program.RunProgram(ctx, func(p program.Program, ctx context.Context) error { - p.Send(program.StatusMsg("Waiting response from world forge service...")) - - pollURL := fmt.Sprintf("%s/auth/cli/login/%s", worldForgeBaseURL, sessionID) - accessToken, err := pollForAccessToken(ctx, pollURL) - - if err != nil { - return err - } - - token, err = encryption.DecryptAccessToken(accessToken.AccessToken, accessToken.PublicKey, accessToken.Nonce) - if err != nil { - return err - } - - if err := globalconfig.SetWorldForgeToken(tokenName, token); err != nil { - logger.Error("Failed to set access token", err) - return err - } - - return nil - }); err != nil { - logger.Error("Failed to get access token", err) - return err - } - - fmt.Println(style.TickIcon.Render("Successfully logged in :")) - // Print the token - credential, err := globalconfig.GetWorldForgeCredential() - if err != nil { - logger.Warn("Failed to get the access token when print", err) - } - stringCredential, err := json.MarshalIndent(credential, "", " ") - if err != nil { - logger.Warn("Failed to marshal the access token when print", err) - } - fmt.Println(style.BoldText.Render(string(stringCredential))) - return nil -} - -func generateTokenName() (string, error) { - user, err := user.Current() - if err != nil { - return "", eris.Wrap(err, "cannot retrieve current user") - } - - hostname, err := os.Hostname() - if err != nil { - return "", eris.Wrap(err, "cannot retrieve hostname") - } - - return fmt.Sprintf("cli_%s@%s_%d", user.Username, hostname, time.Now().Unix()), nil -} - -func generateTokenNameWithFallback() string { - name, err := generateTokenName() - if err != nil { - name = fmt.Sprintf("cli_%d", time.Now().Unix()) - } - return name -} - -func pollForAccessToken(ctx context.Context, url string) (login.AccessTokenResponse, error) { - var accessTokenResponse login.AccessTokenResponse - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return accessTokenResponse, eris.Wrap(err, "cannot fetch access token") - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return accessTokenResponse, eris.Wrap(err, "cannot fetch access token") - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound { - retryAfterSeconds, err := strconv.Atoi(resp.Header.Get("Retry-After")) - if err != nil { - retryAfterSeconds = defaultRetryAfterSeconds - } - t := time.NewTimer(time.Duration(retryAfterSeconds) * time.Second) - select { - case <-ctx.Done(): - t.Stop() - case <-t.C: - } - return pollForAccessToken(ctx, url) - } - - if resp.StatusCode == http.StatusOK { - body, err := io.ReadAll(resp.Body) - - if err != nil { - return accessTokenResponse, eris.Wrap(err, "cannot read access token response body") - } - - if err := json.Unmarshal(body, &accessTokenResponse); err != nil { - return accessTokenResponse, eris.Wrap(err, "cannot unmarshal access token response") - } - - return accessTokenResponse, nil - } - - return accessTokenResponse, errors.Errorf("HTTP %s: cannot retrieve access token", resp.Status) -} diff --git a/cmd/world/root/root.go b/cmd/world/root/root.go index e821d38..3b41968 100644 --- a/cmd/world/root/root.go +++ b/cmd/world/root/root.go @@ -66,8 +66,7 @@ func init() { // Register base commands doctorCmd := getDoctorCmd(os.Stdout) createCmd := getCreateCmd(os.Stdout) - loginCmd := getLoginCmd() - rootCmd.AddCommand(createCmd, doctorCmd, versionCmd, loginCmd) + rootCmd.AddCommand(createCmd, doctorCmd, versionCmd) // Register subcommands rootCmd.AddCommand(cardinal.BaseCmd) diff --git a/cmd/world/root/root_test.go b/cmd/world/root/root_test.go index 958f9f9..49e49d3 100644 --- a/cmd/world/root/root_test.go +++ b/cmd/world/root/root_test.go @@ -6,20 +6,13 @@ import ( "errors" "fmt" "net" - "net/http" - "net/http/httptest" "os" - "os/user" "strings" "testing" "time" "github.com/spf13/cobra" - tassert "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "gotest.tools/v3/assert" - - "pkg.world.dev/world-cli/common/login" ) // outputFromCmd runs the rootCmd with the given cmd arguments and returns the output of the command along with @@ -132,7 +125,7 @@ func TestCreateStartStopRestartPurge(t *testing.T) { assert.NilError(t, err) // Start cardinal - rootCmd.SetArgs([]string{"cardinal", "start", "--build", "--detach", "--editor=false"}) + rootCmd.SetArgs([]string{"cardinal", "start", "--detach", "--editor=false"}) err = rootCmd.Execute() assert.NilError(t, err) @@ -205,6 +198,10 @@ func TestDev(t *testing.T) { } func TestCheckLatestVersion(t *testing.T) { + t.Cleanup(func() { + AppVersion = "" + }) + t.Run("success scenario", func(t *testing.T) { AppVersion = "v1.0.0" err := checkLatestVersion() @@ -219,12 +216,28 @@ func TestCheckLatestVersion(t *testing.T) { } func cardinalIsUp(t *testing.T) bool { + return ServiceIsUp("Cardinal", "localhost:4040", t) +} + +func cardinalIsDown(t *testing.T) bool { + return ServiceIsDown("Cardinal", "localhost:4040", t) +} + +func evmIsUp(t *testing.T) bool { + return ServiceIsUp("EVM", "localhost:9601", t) +} + +func evmIsDown(t *testing.T) bool { + return ServiceIsDown("EVM", "localhost:9601", t) +} + +func ServiceIsUp(name, address string, t *testing.T) bool { up := false for i := 0; i < 60; i++ { - conn, err := net.DialTimeout("tcp", "localhost:4040", time.Second) + conn, err := net.DialTimeout("tcp", address, time.Second) if err != nil { time.Sleep(time.Second) - t.Log("Failed to connect to Cardinal, retrying...") + t.Logf("%s is not running, retrying...\n", name) continue } _ = conn.Close() @@ -234,107 +247,59 @@ func cardinalIsUp(t *testing.T) bool { return up } -func cardinalIsDown(t *testing.T) bool { +func ServiceIsDown(name, address string, t *testing.T) bool { down := false for i := 0; i < 60; i++ { - conn, err := net.DialTimeout("tcp", "localhost:4040", time.Second) + conn, err := net.DialTimeout("tcp", address, time.Second) if err != nil { down = true break } _ = conn.Close() time.Sleep(time.Second) - t.Log("Cardinal is still running, retrying...") + t.Logf("%s is still running, retrying...\n", name) continue } return down } -func TestGenerateTokenNameWithFallback(t *testing.T) { - // Attempt to generate a token name - name := generateTokenNameWithFallback() +func TestEVMStart(t *testing.T) { + // Create Cardinal + gameDir, err := os.MkdirTemp("", "game-template-dev") + assert.NilError(t, err) - // Ensure the name follows the expected pattern - tassert.Contains(t, name, "cli_") + // Remove dir + defer func() { + err = os.RemoveAll(gameDir) + assert.NilError(t, err) + }() - // Additional checks if user and hostname can be retrieved in the environment - currentUser, userErr := user.Current() - hostname, hostErr := os.Hostname() - if userErr == nil && hostErr == nil { - expectedPrefix := fmt.Sprintf("cli_%s@%s_", currentUser.Username, hostname) - tassert.Contains(t, name, expectedPrefix) - } -} + // Change dir + err = os.Chdir(gameDir) + assert.NilError(t, err) -func TestPollForAccessToken(t *testing.T) { - tests := []struct { - name string - statusCode int - retryAfterHeader string - responseBody string - expectError bool - expectedResponse login.AccessTokenResponse - }{ - { - name: "Successful token retrieval", - statusCode: http.StatusOK, - responseBody: `{"access_token": "test_token", "pub_key": "test_pub_key", "nonce": "test_nonce"}`, - expectedResponse: login.AccessTokenResponse{ - AccessToken: "test_token", - PublicKey: "test_pub_key", - Nonce: "test_nonce", - }, - expectError: false, - }, - { - name: "Retry on 404 with Retry-After header", - statusCode: http.StatusNotFound, - retryAfterHeader: "1", - expectError: true, - }, - { - name: "Retry on 404 without Retry-After header", - statusCode: http.StatusNotFound, - retryAfterHeader: "", - expectError: true, - }, - { - name: "Error on invalid JSON response", - statusCode: http.StatusOK, - responseBody: `invalid_json`, - expectError: true, - }, - { - name: "Error on non-200/404 status", - statusCode: http.StatusInternalServerError, - expectError: true, - }, - } + // set tea ouput to variable + teaOut := &bytes.Buffer{} + createCmd := getCreateCmd(teaOut) + createCmd.SetArgs([]string{gameDir}) - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - if test.retryAfterHeader != "" { - w.Header().Set("Retry-After", test.retryAfterHeader) - } - w.WriteHeader(test.statusCode) - w.Write([]byte(test.responseBody)) //nolint:errcheck // Ignore error for test - }) - - server := httptest.NewServer(handler) - defer server.Close() - - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - - response, err := pollForAccessToken(ctx, server.URL) - - if test.expectError { - require.Error(t, err) - } else { - require.NoError(t, err) - tassert.Equal(t, test.expectedResponse, response) - } - }) - } + err = createCmd.Execute() + assert.NilError(t, err) + + // Start evn dev + ctx, cancel := context.WithCancel(context.Background()) + rootCmd.SetArgs([]string{"evm", "start", "--dev"}) + go func() { + err := rootCmd.ExecuteContext(ctx) + assert.NilError(t, err) + }() + + // Check and wait until evm is up + assert.Assert(t, evmIsUp(t), "EVM is not running") + + // Shutdown the program + cancel() + + // Check and wait until evm is down + assert.Assert(t, evmIsDown(t), "EVM is not successfully shutdown") } diff --git a/common/config/config.go b/common/config/config.go index c3dfb4a..9b49355 100644 --- a/common/config/config.go +++ b/common/config/config.go @@ -33,6 +33,7 @@ type Config struct { Detach bool Build bool Debug bool + DevDA bool Timeout int DockerEnv map[string]string } diff --git a/common/docker/client.go b/common/docker/client.go new file mode 100644 index 0000000..2a13ad8 --- /dev/null +++ b/common/docker/client.go @@ -0,0 +1,189 @@ +package docker + +import ( + "bytes" + "context" + "encoding/binary" + "io" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + "github.com/rotisserie/eris" + + "pkg.world.dev/world-cli/common/config" + "pkg.world.dev/world-cli/common/docker/service" + "pkg.world.dev/world-cli/common/logger" +) + +type Client struct { + client *client.Client + cfg *config.Config +} + +func NewClient(cfg *config.Config) (*Client, error) { + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return nil, eris.Wrap(err, "Failed to create docker client") + } + + // Set BuildkitSupport + service.BuildkitSupport = checkBuildkitSupport(cli) + + return &Client{ + client: cli, + cfg: cfg, + }, nil +} + +func (c *Client) Close() error { + return c.client.Close() +} + +func (c *Client) Start(ctx context.Context, cfg *config.Config, + serviceBuilders ...service.Builder) error { + defer func() { + if !cfg.Detach { + err := c.Stop(cfg, serviceBuilders...) + if err != nil { + logger.Error("Failed to stop containers", err) + } + } + }() + + namespace := cfg.DockerEnv["CARDINAL_NAMESPACE"] + err := c.createNetworkIfNotExists(ctx, namespace) + if err != nil { + return eris.Wrap(err, "Failed to create network") + } + + err = c.createVolumeIfNotExists(ctx, namespace) + if err != nil { + return eris.Wrap(err, "Failed to create volume") + } + + // get all services + dockerServices := make([]service.Service, 0) + for _, sb := range serviceBuilders { + dockerServices = append(dockerServices, sb(cfg)) + } + + // Pull all images before starting containers + err = c.pullImages(ctx, dockerServices...) + if err != nil { + return eris.Wrap(err, "Failed to pull images") + } + + // Start all containers + for _, dockerService := range dockerServices { + // build image if needed + if cfg.Build && dockerService.Dockerfile != "" { + if err := c.buildImage(ctx, dockerService.Dockerfile, dockerService.BuildTarget, dockerService.Image); err != nil { + return eris.Wrap(err, "Failed to build image") + } + } + + // create container & start + if err := c.startContainer(ctx, dockerService); err != nil { + return eris.Wrap(err, "Failed to create container") + } + } + + // log containers if not detached + if !cfg.Detach { + c.logMultipleContainers(ctx, dockerServices...) + } + + return nil +} + +func (c *Client) Stop(cfg *config.Config, serviceBuilders ...service.Builder) error { + ctx := context.Background() + for _, sb := range serviceBuilders { + dockerService := sb(cfg) + if err := c.stopContainer(ctx, dockerService.Name); err != nil { + return eris.Wrap(err, "Failed to stop container") + } + } + + return nil +} + +func (c *Client) Purge(cfg *config.Config, serviceBuilders ...service.Builder) error { + ctx := context.Background() + for _, sb := range serviceBuilders { + dockerService := sb(cfg) + if err := c.removeContainer(ctx, dockerService.Name); err != nil { + return eris.Wrap(err, "Failed to remove container") + } + } + + err := c.removeVolume(ctx, cfg.DockerEnv["CARDINAL_NAMESPACE"]) + if err != nil { + return err + } + + return nil +} + +func (c *Client) Restart(ctx context.Context, cfg *config.Config, + serviceBuilders ...service.Builder) error { + // stop containers + err := c.Stop(cfg, serviceBuilders...) + if err != nil { + return err + } + + return c.Start(ctx, cfg, serviceBuilders...) +} + +func (c *Client) Exec(ctx context.Context, containerID string, cmd []string) (string, error) { + // Create Exec Instance + exec := container.ExecOptions{ + Cmd: cmd, + AttachStdout: true, + AttachStderr: true, + } + + execIDResp, err := c.client.ContainerExecCreate(ctx, containerID, exec) + if err != nil { + return "", eris.Wrapf(err, "Failed to create exec instance") + } + + // Start Exec Instance + resp, err := c.client.ContainerExecAttach(ctx, execIDResp.ID, container.ExecAttachOptions{}) + if err != nil { + return "", eris.Wrapf(err, "Failed to start exec instance") + } + defer resp.Close() + + // Read and demultiplex the output + var outputBuf bytes.Buffer + header := make([]byte, 8) //nolint:gomnd + + for { + _, err := io.ReadFull(resp.Reader, header) + if err != nil { + if err == io.EOF { + break + } + return "", eris.Wrapf(err, "Failed to read exec output") + } + + stream := header[0] + size := binary.BigEndian.Uint32(header[4:8]) + + if stream == 1 { // stdout + if _, err := io.CopyN(&outputBuf, resp.Reader, int64(size)); err != nil { + return "", eris.Wrapf(err, "Failed to read stdout") + } + } else { + // Skip stderr or other streams + if _, err := io.CopyN(io.Discard, resp.Reader, int64(size)); err != nil { + return "", eris.Wrapf(err, "Failed to read stderr") + } + } + } + + // Return the output as a string + return outputBuf.String(), nil +} diff --git a/common/docker/client_container.go b/common/docker/client_container.go new file mode 100644 index 0000000..d4c948c --- /dev/null +++ b/common/docker/client_container.go @@ -0,0 +1,167 @@ +package docker + +import ( + "context" + "errors" + "fmt" + "io" + "strconv" + "sync" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + "github.com/rotisserie/eris" + + "pkg.world.dev/world-cli/common/docker/service" + "pkg.world.dev/world-cli/tea/style" +) + +func (c *Client) startContainer(ctx context.Context, service service.Service) error { + contextPrint("Starting", "2", "container", service.Name) + + // Check if the container exists + exist, err := c.containerExists(ctx, service.Name) + if err != nil { + return eris.Wrapf(err, "Failed to check if container %s exists", service.Name) + } else if !exist { + // Create the container if it does not exist + _, err := c.client.ContainerCreate(ctx, &service.Config, &service.HostConfig, + &service.NetworkingConfig, &service.Platform, service.Name) + if err != nil { + fmt.Println(style.CrossIcon.Render()) + return err + } + } + + // Start the container + if err := c.client.ContainerStart(ctx, service.Name, container.StartOptions{}); err != nil { + fmt.Println(style.CrossIcon.Render()) + return err + } + + fmt.Println(style.TickIcon.Render()) + return nil +} + +func (c *Client) containerExists(ctx context.Context, containerName string) (bool, error) { + _, err := c.client.ContainerInspect(ctx, containerName) + if err != nil { + if client.IsErrNotFound(err) { + return false, nil + } + return false, eris.Wrapf(err, "Failed to inspect container %s", containerName) + } + + return true, nil +} + +func (c *Client) stopContainer(ctx context.Context, containerName string) error { + contextPrint("Stopping", "1", "container", containerName) + + // Check if the container exists + exist, err := c.containerExists(ctx, containerName) + if !exist { + fmt.Println(style.TickIcon.Render()) + return err + } + + // Stop the container + err = c.client.ContainerStop(ctx, containerName, container.StopOptions{}) + if err != nil { + fmt.Println(style.CrossIcon.Render()) + return eris.Wrapf(err, "Failed to stop container %s", containerName) + } + + fmt.Println(style.TickIcon.Render()) + return nil +} + +func (c *Client) removeContainer(ctx context.Context, containerName string) error { + contextPrint("Removing", "1", "container", containerName) + + // Check if the container exists + exist, err := c.containerExists(ctx, containerName) + if !exist { + fmt.Println(style.TickIcon.Render()) + return err + } + + // Stop the container + err = c.client.ContainerStop(ctx, containerName, container.StopOptions{}) + if err != nil { + fmt.Println(style.CrossIcon.Render()) + return eris.Wrapf(err, "Failed to stop container %s", containerName) + } + + // Remove the container + err = c.client.ContainerRemove(ctx, containerName, container.RemoveOptions{}) + if err != nil { + fmt.Println(style.CrossIcon.Render()) + return eris.Wrapf(err, "Failed to remove container %s", containerName) + } + + fmt.Println(style.TickIcon.Render()) + return nil +} + +func (c *Client) logMultipleContainers(ctx context.Context, services ...service.Service) { + var wg sync.WaitGroup + + // Start logging output for each container + for i, dockerService := range services { + wg.Add(1) + go func(id string) { + defer wg.Done() + for { + select { + case <-ctx.Done(): + fmt.Printf("Stopping logging for container %s: %v\n", id, ctx.Err()) + return + default: + err := c.logContainerOutput(ctx, id, strconv.Itoa(i)) + if err != nil && !errors.Is(err, context.Canceled) { + fmt.Printf("Error logging container %s: %v. Reattaching...\n", id, err) + time.Sleep(2 * time.Second) //nolint:gomnd // Sleep for 2 seconds before reattaching + } + } + } + }(dockerService.Name) + } + + // Wait for all logging goroutines to finish + wg.Wait() +} + +func (c *Client) logContainerOutput(ctx context.Context, containerID, style string) error { + // Create options for logs + options := container.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: true, + } + + // Fetch logs from the container + out, err := c.client.ContainerLogs(ctx, containerID, options) + if err != nil { + return err + } + defer out.Close() + + // Print logs + buf := make([]byte, 4096) //nolint:gomnd + for { + n, err := out.Read(buf) + if n > 0 { + fmt.Printf("[%s] %s", foregroundPrint(containerID, style), buf[:n]) + } + if err != nil { + if err == io.EOF { + break + } + return err + } + } + + return nil +} diff --git a/common/docker/client_image.go b/common/docker/client_image.go new file mode 100644 index 0000000..35c90cd --- /dev/null +++ b/common/docker/client_image.go @@ -0,0 +1,385 @@ +package docker + +import ( + "archive/tar" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/pkg/jsonmessage" + controlapi "github.com/moby/buildkit/api/services/control" + "github.com/rotisserie/eris" + "github.com/vbauerster/mpb/v8" + "github.com/vbauerster/mpb/v8/decor" + + "pkg.world.dev/world-cli/common/docker/service" + "pkg.world.dev/world-cli/common/logger" + teaspinner "pkg.world.dev/world-cli/tea/component/spinner" +) + +func (c *Client) buildImage(ctx context.Context, dockerfile, target, imageName string) error { + contextPrint("Building", "2", "image", imageName) + fmt.Println() // Add a newline after the context print + buf := new(bytes.Buffer) + tw := tar.NewWriter(buf) + defer tw.Close() + + // Add the Dockerfile to the tar archive + header := &tar.Header{ + Name: "Dockerfile", + Size: int64(len(dockerfile)), + } + if err := tw.WriteHeader(header); err != nil { + return eris.Wrap(err, "Failed to write header to tar writer") + } + if _, err := tw.Write([]byte(dockerfile)); err != nil { + return eris.Wrap(err, "Failed to write Dockerfile to tar writer") + } + + // Add source code to the tar archive + if err := c.addFileToTarWriter(".", tw); err != nil { + return eris.Wrap(err, "Failed to add source code to tar writer") + } + + // Read the tar archive + tarReader := bytes.NewReader(buf.Bytes()) + + buildOptions := types.ImageBuildOptions{ + Dockerfile: "Dockerfile", + Tags: []string{imageName}, + Target: target, + } + + if service.BuildkitSupport { + buildOptions.Version = types.BuilderBuildKit + } + + // Build the image + buildResponse, err := c.client.ImageBuild(ctx, tarReader, buildOptions) + if err != nil { + return err + } + defer buildResponse.Body.Close() + + // Print the build logs + c.readBuildLog(ctx, buildResponse.Body) + + return nil +} + +// AddFileToTarWriter adds a file or directory to the tar writer +// This function is used to add the Dockerfile and source code to the tar archive +// The tar file is used to build the Docker image +func (c *Client) addFileToTarWriter(baseDir string, tw *tar.Writer) error { + return filepath.Walk(baseDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return eris.Wrapf(err, "Failed to walk the directory %s", baseDir) + } + + // Check if the file is world.toml or inside the cardinal directory + relPath, err := filepath.Rel(baseDir, path) + if err != nil { + return eris.Wrapf(err, "Failed to get relative path %s", path) + } + // Skip files that are not world.toml or inside the cardinal directory + if !(info.Name() == "world.toml" || strings.HasPrefix(filepath.ToSlash(relPath), "cardinal/")) { + return nil + } + + // Create a tar header for the file or directory + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return eris.Wrap(err, "Failed to create tar header") + } + + // Adjust the header name to be relative to the baseDir + header.Name = filepath.ToSlash(relPath) + + // Write the header to the tar writer + if err := tw.WriteHeader(header); err != nil { + return eris.Wrap(err, "Failed to write header to tar writer") + } + + // If it's a directory, there's no need to write file content + if info.IsDir() { + return nil + } + + // Write the file content to the tar writer + file, err := os.Open(path) + if err != nil { + return eris.Wrapf(err, "Failed to open file %s", path) + } + defer file.Close() + + if _, err := io.Copy(tw, file); err != nil { + return eris.Wrap(err, "Failed to copy file to tar writer") + } + + return nil + }) +} + +// readBuildLog filters the output of the Docker build command +// there is two types of output: +// 1. Output from the build process without buildkit +// - stream: Output from the build process +// - error: Error message from the build process +// +// 2. Output from the build process with buildkit +func (c *Client) readBuildLog(ctx context.Context, reader io.Reader) { + // Create context with cancel + ctx, cancel := context.WithCancel(ctx) + + // Create a new JSON decoder + decoder := json.NewDecoder(reader) + + // Initialize the spinner + s := spinner.New() + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("69")) + s.Spinner = spinner.Points + + // Initialize the model + m := teaspinner.Spinner{ + Spinner: s, + Cancel: cancel, + } + + // Start the bubbletea program + p := tea.NewProgram(m) + go func() { + for stop := false; !stop; { + select { + case <-ctx.Done(): + stop = true + default: + var step string + if service.BuildkitSupport { + // Parse the buildkit response + step = c.parseBuildkitResp(decoder, &stop) + } else { + // Parse the non-buildkit response + step = c.parseNonBuildkitResp(decoder, &stop) + } + + // Send the step to the spinner + if step != "" { + p.Send(teaspinner.LogMsg(step)) + } + } + } + // Send a completion message to the spinner + p.Send(teaspinner.LogMsg("spin: completed")) + }() + + // Run the program + if _, err := p.Run(); err != nil { + fmt.Println("Error running program:", err) + } +} + +func (c *Client) parseBuildkitResp(decoder *json.Decoder, stop *bool) string { + var msg jsonmessage.JSONMessage + if err := decoder.Decode(&msg); errors.Is(err, io.EOF) { + *stop = true + } else if err != nil { + logger.Errorf("Error decoding build output: %v", err) + } + + var resp controlapi.StatusResponse + + if msg.ID != "moby.buildkit.trace" { + return "" + } + + var dt []byte + // ignoring all messages that are not understood + if err := json.Unmarshal(*msg.Aux, &dt); err != nil { + return "" + } + if err := (&resp).Unmarshal(dt); err != nil { + return "" + } + + if len(resp.Vertexes) == 0 { + return "" + } + + // return the name of the vertex (step) that is currently being executed + return resp.Vertexes[len(resp.Vertexes)-1].Name +} + +func (c *Client) parseNonBuildkitResp(decoder *json.Decoder, stop *bool) string { + var event map[string]interface{} + if err := decoder.Decode(&event); errors.Is(err, io.EOF) { + *stop = true + } else if err != nil { + logger.Errorf("Error decoding build output: %v", err) + } + + // Only show the step if it's a build step + step := "" + if val, ok := event["stream"]; ok && val != "" && strings.HasPrefix(val.(string), "Step") { + if step, ok = val.(string); ok && step != "" { + step = strings.TrimSpace(step) + } + } else if val, ok = event["error"]; ok && val != "" { + logger.Errorf("Error building image: %v", val) + } + + return step +} + +// filterImages filters the images that need to be pulled +// Remove duplicates +// Remove images that are already pulled +// Remove images that need to be built +func (c *Client) filterImages(ctx context.Context, images map[string]struct{}, services ...service.Service) { + for _, service := range services { + // check if the image exists + _, _, err := c.client.ImageInspectWithRaw(ctx, service.Image) + if err == nil { + // Image already exists, skip pulling + continue + } + + // check if the image needs to be built + // if the service has a Dockerfile, it needs to be built + if service.Dockerfile == "" { + // Image does not exist and does not need to be built + // Add the image to the list of images to pull + images[service.Image] = struct{}{} + } + + // Recursively check dependencies + if service.Dependencies != nil { + c.filterImages(ctx, images, service.Dependencies...) + } + } +} + +// Pulls the image if it does not exist +func (c *Client) pullImages(ctx context.Context, services ...service.Service) error { //nolint:gocognit + // Filter the images that need to be pulled + images := make(map[string]struct{}) + c.filterImages(ctx, images, services...) + + // Create a new progress container with a wait group + var wg sync.WaitGroup + p := mpb.New(mpb.WithWaitGroup(&wg)) + + // Channel to collect errors from the goroutines + errChan := make(chan error, len(images)) + + // Add a wait group counter for each image + wg.Add(len(images)) + + // Pull each image concurrently + for imageName := range images { + // Capture imageName in the loop + imageName := imageName + + // Create a new progress bar for this image + bar := p.AddBar(100, //nolint:gomnd + mpb.PrependDecorators( + decor.Name(fmt.Sprintf("Pulling %s: ", imageName)), + decor.Percentage(decor.WCSyncSpace), + ), + mpb.AppendDecorators( + decor.OnComplete( + decor.EwmaETA(decor.ET_STYLE_GO, 30, decor.WCSyncWidth), "done", //nolint:gomnd + ), + ), + ) + + go func() { + defer wg.Done() + + // Start pulling the image + responseBody, err := c.client.ImagePull(ctx, imageName, image.PullOptions{}) + if err != nil { + // Handle the error: log it and send it to the error channel + fmt.Printf("Error pulling image %s: %v\n", imageName, err) + errChan <- fmt.Errorf("error pulling image %s: %w", imageName, err) + + // Stop the progress bar without clearing + bar.Abort(false) + return + } + defer responseBody.Close() + + // Process each event and update the progress bar + decoder := json.NewDecoder(responseBody) + var current int + var event map[string]interface{} + for decoder.More() { + select { + case <-ctx.Done(): + // Handle context cancellation + fmt.Printf("Pulling of image %s was canceled\n", imageName) + bar.Abort(false) // Stop the progress bar without clearing + return + default: + if err := decoder.Decode(&event); err != nil { + fmt.Printf("Error decoding event for %s: %v\n", imageName, err) + continue + } + + // Check for errorDetail and error fields + if errorDetail, ok := event["errorDetail"]; ok { + if errorMessage, ok := errorDetail.(map[string]interface{})["message"]; ok { + fmt.Println(foregroundPrint("Error:", "1"), errorMessage) + continue + } + } else if errorMsg, ok := event["error"]; ok { + fmt.Println(foregroundPrint("Error:", "1"), errorMsg) + continue + } + + // Handle progress updates + if progressDetail, ok := event["progressDetail"].(map[string]interface{}); ok { + if total, ok := progressDetail["total"].(float64); ok && total > 0 { + current = int(progressDetail["current"].(float64) * 100 / total) + bar.SetCurrent(int64(current)) + } + } + } + } + + // Finish the progress bar + // Handle if the current and total is not available in the response body + // Usually, because docker image is already pulled from the cache + bar.SetCurrent(100) //nolint:gomnd + }() + } + + // Wait for all progress bars to complete + wg.Wait() + p.Wait() + + // Close the error channel and check for errors + close(errChan) + errs := make([]error, 0) + for err := range errChan { + errs = append(errs, err) + } + + // If there were any errors, return them as a combined error + if len(errs) > 0 { + return eris.Wrapf(errors.New("error pulling images"), "Errors: %v", errs) + } + + return nil +} diff --git a/common/docker/client_network.go b/common/docker/client_network.go new file mode 100644 index 0000000..d027d2c --- /dev/null +++ b/common/docker/client_network.go @@ -0,0 +1,32 @@ +package docker + +import ( + "context" + + "github.com/docker/docker/api/types/network" + + "pkg.world.dev/world-cli/common/logger" +) + +func (c *Client) createNetworkIfNotExists(ctx context.Context, networkName string) error { + networks, err := c.client.NetworkList(ctx, network.ListOptions{}) + if err != nil { + return err + } + + for _, network := range networks { + if network.Name == networkName { + logger.Infof("Network %s already exists", networkName) + return nil + } + } + + _, err = c.client.NetworkCreate(ctx, networkName, network.CreateOptions{ + Driver: "bridge", + }) + if err != nil { + return err + } + + return nil +} diff --git a/common/docker/client_test.go b/common/docker/client_test.go new file mode 100644 index 0000000..e910519 --- /dev/null +++ b/common/docker/client_test.go @@ -0,0 +1,233 @@ +package docker + +import ( + "context" + "net" + "os" + "testing" + "time" + + "gotest.tools/v3/assert" + + "pkg.world.dev/world-cli/common/config" + "pkg.world.dev/world-cli/common/docker/service" + "pkg.world.dev/world-cli/common/logger" + "pkg.world.dev/world-cli/common/teacmd" +) + +const ( + redisPort = "56379" + redisPassword = "password" + cardinalNamespace = "test" +) + +var ( + dockerClient *Client +) + +func TestMain(m *testing.M) { + // Purge any existing containers + cfg := &config.Config{ + DockerEnv: map[string]string{ + "CARDINAL_NAMESPACE": cardinalNamespace, + }, + } + + c, err := NewClient(cfg) + if err != nil { + logger.Errorf("Failed to create docker client: %v", err) + os.Exit(1) + } + + dockerClient = c + + err = dockerClient.Purge(cfg, service.Nakama, service.Cardinal, service.Redis, service.NakamaDB) + if err != nil { + logger.Errorf("Failed to purge containers: %v", err) + os.Exit(1) + } + + // Run the tests + code := m.Run() + + err = dockerClient.Close() + if err != nil { + logger.Errorf("Failed to close docker client: %v", err) + os.Exit(1) + } + + os.Exit(code) +} + +func TestStart(t *testing.T) { + cfg := &config.Config{ + DockerEnv: map[string]string{ + "CARDINAL_NAMESPACE": cardinalNamespace, + "REDIS_PASSWORD": redisPassword, + "REDIS_PORT": redisPort, + }, + Detach: true, + } + + ctx := context.Background() + assert.NilError(t, dockerClient.Start(ctx, cfg, service.Redis), "failed to start container") + cleanUp(t, cfg) + + // Test if the container is running + assert.Assert(t, redislIsUp(t)) +} + +func TestStop(t *testing.T) { + cfg := &config.Config{ + DockerEnv: map[string]string{ + "CARDINAL_NAMESPACE": cardinalNamespace, + "REDIS_PASSWORD": redisPassword, + "REDIS_PORT": redisPort, + }, + Detach: true, + } + + ctx := context.Background() + assert.NilError(t, dockerClient.Start(ctx, cfg, service.Redis), "failed to start container") + cleanUp(t, cfg) + + assert.NilError(t, dockerClient.Stop(cfg, service.Redis), "failed to stop container") + + // Test if the container is stopped + assert.Assert(t, redisIsDown(t)) +} + +func TestRestart(t *testing.T) { + cfg := &config.Config{ + DockerEnv: map[string]string{ + "CARDINAL_NAMESPACE": cardinalNamespace, + "REDIS_PASSWORD": redisPassword, + "REDIS_PORT": redisPort, + }, + Detach: true, + } + + ctx := context.Background() + assert.NilError(t, dockerClient.Start(ctx, cfg, service.Redis), "failed to start container") + cleanUp(t, cfg) + + assert.NilError(t, dockerClient.Restart(ctx, cfg, service.Redis), "failed to restart container") + + // Test if the container is running + assert.Assert(t, redislIsUp(t)) +} + +func TestPurge(t *testing.T) { + cfg := &config.Config{ + DockerEnv: map[string]string{ + "CARDINAL_NAMESPACE": cardinalNamespace, + "REDIS_PASSWORD": redisPassword, + "REDIS_PORT": redisPort, + }, + Detach: true, + } + + ctx := context.Background() + assert.NilError(t, dockerClient.Start(ctx, cfg, service.Redis), "failed to start container") + assert.NilError(t, dockerClient.Purge(cfg, service.Redis), "failed to purge container") + + // Test if the container is stopped + assert.Assert(t, redisIsDown(t)) +} + +func TestStartUndetach(t *testing.T) { + cfg := &config.Config{ + DockerEnv: map[string]string{ + "CARDINAL_NAMESPACE": cardinalNamespace, + "REDIS_PASSWORD": redisPassword, + "REDIS_PORT": redisPort, + }, + } + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + assert.NilError(t, dockerClient.Start(ctx, cfg, service.Redis), "failed to start container") + cleanUp(t, cfg) + }() + assert.Assert(t, redislIsUp(t)) + + cancel() + assert.Assert(t, redisIsDown(t)) +} + +func TestBuild(t *testing.T) { + // Create a temporary directory + dir, err := os.MkdirTemp("", "sgt") + assert.NilError(t, err) + + // Remove dir + defer func() { + err = os.RemoveAll(dir) + assert.NilError(t, err) + }() + + // Change to the temporary directory + err = os.Chdir(dir) + assert.NilError(t, err) + + // Pull the repository + templateGitURL := "https://github.com/Argus-Labs/starter-game-template.git" + err = teacmd.GitCloneCmd(templateGitURL, dir, "Initial commit from World CLI") + assert.NilError(t, err) + + // Preparation + cfg := &config.Config{ + DockerEnv: map[string]string{ + "CARDINAL_NAMESPACE": cardinalNamespace, + }, + } + cardinalService := service.Cardinal(cfg) + ctx := context.Background() + + // Pull prerequisite images + assert.NilError(t, dockerClient.pullImages(ctx, cardinalService)) + + // Build the image + err = dockerClient.buildImage(ctx, cardinalService.Dockerfile, cardinalService.BuildTarget, cardinalService.Image) + assert.NilError(t, err, "Failed to build Docker image") +} + +func redislIsUp(t *testing.T) bool { + up := false + for i := 0; i < 60; i++ { + conn, err := net.DialTimeout("tcp", "localhost:"+redisPort, time.Second) + if err != nil { + time.Sleep(time.Second) + t.Logf("Failed to connect to Redis at localhost:%s, retrying...", redisPort) + continue + } + _ = conn.Close() + up = true + break + } + return up +} + +func redisIsDown(t *testing.T) bool { + down := false + for i := 0; i < 60; i++ { + conn, err := net.DialTimeout("tcp", "localhost:"+redisPort, time.Second) + if err != nil { + down = true + break + } + _ = conn.Close() + time.Sleep(time.Second) + t.Logf("Redis is still running at localhost:%s, retrying...", redisPort) + continue + } + return down +} + +func cleanUp(t *testing.T, cfg *config.Config) { + t.Cleanup(func() { + assert.NilError(t, dockerClient.Purge(cfg, service.Nakama, + service.Cardinal, service.Redis, + service.NakamaDB), "Failed to purge container during cleanup") + }) +} diff --git a/common/docker/client_util.go b/common/docker/client_util.go new file mode 100644 index 0000000..c403e2e --- /dev/null +++ b/common/docker/client_util.go @@ -0,0 +1,53 @@ +package docker + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/docker/docker/client" + + "pkg.world.dev/world-cli/common/logger" +) + +func contextPrint(title, titleColor, subject, object string) { + titleStr := foregroundPrint(title, titleColor) + arrowStr := foregroundPrint("→", "241") + subjectStr := foregroundPrint(subject, "4") + + fmt.Printf("%s %s %s %s ", titleStr, arrowStr, subjectStr, object) +} + +func foregroundPrint(text string, color string) string { + return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(text) +} + +func checkBuildkitSupport(cli *client.Client) bool { + ctx := context.Background() + defer func() { + err := cli.Close() + if err != nil { + logger.Error("Failed to close docker client", err) + } + }() + + // Get Docker server version + version, err := cli.ServerVersion(ctx) + if err != nil { + logger.Warnf("Failed to get Docker server version: %v", err) + return false + } + + // Check if the Docker version supports BuildKit + supportsBuildKit := strings.HasPrefix(version.Version, "18.09") || version.Version > "18.09" + + if !supportsBuildKit { + return false + } + + // Check if DOCKER_BUILDKIT environment variable is set to 1 + buildKitEnv := os.Getenv("DOCKER_BUILDKIT") + return buildKitEnv == "1" +} diff --git a/common/docker/client_volume.go b/common/docker/client_volume.go new file mode 100644 index 0000000..8943e7e --- /dev/null +++ b/common/docker/client_volume.go @@ -0,0 +1,64 @@ +package docker + +import ( + "context" + "fmt" + + "github.com/docker/docker/api/types/volume" + "github.com/rotisserie/eris" + + "pkg.world.dev/world-cli/common/logger" + "pkg.world.dev/world-cli/tea/style" +) + +func (c *Client) createVolumeIfNotExists(ctx context.Context, volumeName string) error { + volumes, err := c.client.VolumeList(ctx, volume.ListOptions{}) + if err != nil { + return err + } + + for _, volume := range volumes.Volumes { + if volume.Name == volumeName { + logger.Debugf("Volume %s already exists\n", volumeName) + return nil + } + } + + _, err = c.client.VolumeCreate(ctx, volume.CreateOptions{Name: volumeName}) + if err != nil { + return err + } + + fmt.Printf("Created volume %s\n", volumeName) + return nil +} + +func (c *Client) removeVolume(ctx context.Context, volumeName string) error { + volumes, err := c.client.VolumeList(ctx, volume.ListOptions{}) + if err != nil { + return eris.Wrap(err, "Failed to list volumes") + } + + isExist := false + for _, v := range volumes.Volumes { + if v.Name == volumeName { + isExist = true + break + } + } + + // Return if volume does not exist + if !isExist { + return nil + } + + contextPrint("Removing", "1", "volume", volumeName) + + err = c.client.VolumeRemove(ctx, volumeName, true) + if err != nil { + return eris.Wrapf(err, "Failed to remove volume %s", volumeName) + } + + fmt.Println(style.TickIcon.Render()) + return nil +} diff --git a/common/docker/service/cardinal.Dockerfile b/common/docker/service/cardinal.Dockerfile new file mode 100644 index 0000000..407fc0d --- /dev/null +++ b/common/docker/service/cardinal.Dockerfile @@ -0,0 +1,61 @@ +################################ +# Build Image - Normal +################################ +FROM golang:1.22-bookworm AS build + +WORKDIR /go/src/app + +# Copy the go module files and download the dependencies +# We do this before copying the rest of the source code to avoid +# having to re-download the dependencies every time we build the image +COPY /cardinal/go.mod /cardinal/go.sum ./ +RUN go mod download + +# Set the GOCACHE environment variable to /root/.cache/go-build to speed up build +ENV GOCACHE=/root/.cache/go-build + +# Copy the rest of the source code and build the binary +COPY /cardinal ./ +RUN --mount=type=cache,target="/root/.cache/go-build" go build -v -o /go/bin/app + +################################ +# Runtime Image - Normal +################################ +FROM gcr.io/distroless/base-debian12 AS runtime + +# Copy world.toml to the image +COPY world.toml world.toml + +# Copy the binary from the build image +COPY --from=build /go/bin/app /usr/bin + +# Run the binary +CMD ["app"] + +################################ +# Runtime Image - Debug +################################ +FROM golang:1.22-bookworm AS runtime-debug + +WORKDIR /go/src/app + +# Install delve +RUN go install github.com/go-delve/delve/cmd/dlv@latest + +# Copy the go module files and download the dependencies +# We do this before copying the rest of the source code to avoid +# having to re-download the dependencies every time we build the image +COPY /cardinal/go.mod /cardinal/go.sum ./ +RUN go mod download + +# Set the GOCACHE environment variable to /root/.cache/go-build to speed up build +ENV GOCACHE=/root/.cache/go-build + +# Copy the rest of the source code and build the binary with debugging symbols +COPY /cardinal ./ +RUN --mount=type=cache,target="/root/.cache/go-build" go build -gcflags="all=-N -l" -v -o /usr/bin/app + +# Copy world.toml to the image +COPY world.toml world.toml + +CMD ["dlv", "--listen=:40000", "--headless=true", "--api-version=2", "--accept-multiclient", "exec", "/usr/bin/app"] \ No newline at end of file diff --git a/common/docker/service/cardinal.go b/common/docker/service/cardinal.go new file mode 100644 index 0000000..c4db96a --- /dev/null +++ b/common/docker/service/cardinal.go @@ -0,0 +1,86 @@ +package service + +import ( + "fmt" + "strings" + + "github.com/docker/docker/api/types/container" + "github.com/docker/go-connections/nat" + + "pkg.world.dev/world-cli/common/config" + + _ "embed" +) + +const ( + // mountCache is the Docker mount command to cache the go build cache + mountCacheScript = `--mount=type=cache,target="/root/.cache/go-build"` +) + +//go:embed cardinal.Dockerfile +var dockerfileContent string + +func getCardinalContainerName(cfg *config.Config) string { + return fmt.Sprintf("%s-cardinal", cfg.DockerEnv["CARDINAL_NAMESPACE"]) +} + +func Cardinal(cfg *config.Config) Service { + // Check cardinal namespace + checkCardinalNamespace(cfg) + + exposedPorts := []int{4040} + + runtime := "runtime" + if cfg.Debug { + runtime = "runtime-debug" + } + + dockerfile := dockerfileContent + if !BuildkitSupport { + dockerfile = strings.ReplaceAll(dockerfile, mountCacheScript, "") + } + + service := Service{ + Name: getCardinalContainerName(cfg), + Config: container.Config{ + Image: cfg.DockerEnv["CARDINAL_NAMESPACE"], + Env: []string{ + fmt.Sprintf("REDIS_ADDRESS=%s:6379", getRedisContainerName(cfg)), + fmt.Sprintf("BASE_SHARD_SEQUENCER_ADDRESS=%s:9601", getEVMContainerName(cfg)), + }, + ExposedPorts: getExposedPorts(exposedPorts), + }, + HostConfig: container.HostConfig{ + PortBindings: newPortMap(exposedPorts), + RestartPolicy: container.RestartPolicy{Name: "unless-stopped"}, + NetworkMode: container.NetworkMode(cfg.DockerEnv["CARDINAL_NAMESPACE"]), + }, + Dockerfile: dockerfile, + BuildTarget: runtime, + Dependencies: []Service{ + { + Name: "golang:1.22-bookworm", + Config: container.Config{ + Image: "golang:1.22-bookworm", + }, + }, + { + Name: "gcr.io/distroless/base-debian12", + Config: container.Config{ + Image: "gcr.io/distroless/base-debian12", + }, + }, + }, + } + + // Add debug options + debug := cfg.Debug + if debug { + service.Config.ExposedPorts["40000/tcp"] = struct{}{} + service.HostConfig.PortBindings["40000/tcp"] = []nat.PortBinding{{HostPort: "40000"}} + service.HostConfig.CapAdd = []string{"SYS_PTRACE"} + service.HostConfig.SecurityOpt = []string{"seccomp:unconfined"} + } + + return service +} diff --git a/common/docker/service/celestia.go b/common/docker/service/celestia.go new file mode 100644 index 0000000..898729a --- /dev/null +++ b/common/docker/service/celestia.go @@ -0,0 +1,44 @@ +package service + +import ( + "fmt" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/go-connections/nat" + + "pkg.world.dev/world-cli/common/config" +) + +func getCelestiaDevNetContainerName(cfg *config.Config) string { + return fmt.Sprintf("%s-celestia-devnet", cfg.DockerEnv["CARDINAL_NAMESPACE"]) +} + +func CelestiaDevNet(cfg *config.Config) Service { + // Check cardinal namespace + checkCardinalNamespace(cfg) + + return Service{ + Name: getCelestiaDevNetContainerName(cfg), + Config: container.Config{ + Image: "ghcr.io/rollkit/local-celestia-devnet:latest", + ExposedPorts: getExposedPorts([]int{26658, 26659}), + Healthcheck: &container.HealthConfig{ + Test: []string{"CMD", "curl", "-f", "http://127.0.0.1:26659/head"}, + Interval: 1 * time.Second, + Timeout: 1 * time.Second, + Retries: 20, //nolint:gomnd + }, + }, + HostConfig: container.HostConfig{ + PortBindings: nat.PortMap{ + "26657/tcp": []nat.PortBinding{}, + "26658/tcp": []nat.PortBinding{{HostPort: "26658"}}, + "26659/tcp": []nat.PortBinding{{HostPort: "26659"}}, + "9090/tcp": []nat.PortBinding{}, + }, + RestartPolicy: container.RestartPolicy{Name: "on-failure"}, + NetworkMode: container.NetworkMode(cfg.DockerEnv["CARDINAL_NAMESPACE"]), + }, + } +} diff --git a/common/docker/service/evm.go b/common/docker/service/evm.go new file mode 100644 index 0000000..d2ef94f --- /dev/null +++ b/common/docker/service/evm.go @@ -0,0 +1,64 @@ +package service + +import ( + "fmt" + + "github.com/docker/docker/api/types/container" + + "pkg.world.dev/world-cli/common/config" +) + +func getEVMContainerName(cfg *config.Config) string { + return fmt.Sprintf("%s-evm", cfg.DockerEnv["CARDINAL_NAMESPACE"]) +} + +func EVM(cfg *config.Config) Service { + // Check cardinal namespace + checkCardinalNamespace(cfg) + + daBaseURL := cfg.DockerEnv["DA_BASE_URL"] + if daBaseURL == "" || cfg.DevDA { + daBaseURL = fmt.Sprintf("http://%s", getCelestiaDevNetContainerName(cfg)) + } + + faucetEnabled := cfg.DockerEnv["FAUCET_ENABLED"] + if faucetEnabled == "" { + faucetEnabled = "false" + } + + faucetAddress := cfg.DockerEnv["FAUCET_ADDRESS"] + if faucetAddress == "" { + faucetAddress = "aa9288F88233Eb887d194fF2215Cf1776a6FEE41" + } + + faucetAmount := cfg.DockerEnv["FAUCET_AMOUNT"] + if faucetAmount == "" { + faucetAmount = "0x56BC75E2D63100000" + } + + baseShardRouterKey := cfg.DockerEnv["BASE_SHARD_ROUTER_KEY"] + if baseShardRouterKey == "" { + baseShardRouterKey = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ01" + } + + return Service{ + Name: getEVMContainerName(cfg), + Config: container.Config{ + Image: "ghcr.io/argus-labs/world-engine-evm:1.4.1", + Env: []string{ + fmt.Sprintf("DA_BASE_URL=%s", daBaseURL), + fmt.Sprintf("DA_AUTH_TOKEN=%s", cfg.DockerEnv["DA_AUTH_TOKEN"]), + fmt.Sprintf("FAUCET_ENABLED=%s", faucetEnabled), + fmt.Sprintf("FAUCET_ADDRESS=%s", faucetAddress), + fmt.Sprintf("FAUCET_AMOUNT=%s", faucetAmount), + fmt.Sprintf("BASE_SHARD_ROUTER_KEY=%s", baseShardRouterKey), + }, + ExposedPorts: getExposedPorts([]int{1317, 26657, 9090, 9601}), + }, + HostConfig: container.HostConfig{ + PortBindings: newPortMap([]int{1317, 26657, 9090, 9601, 8545}), + RestartPolicy: container.RestartPolicy{Name: "unless-stopped"}, + NetworkMode: container.NetworkMode(cfg.DockerEnv["CARDINAL_NAMESPACE"]), + }, + } +} diff --git a/common/docker/service/nakama.go b/common/docker/service/nakama.go new file mode 100644 index 0000000..c01bc93 --- /dev/null +++ b/common/docker/service/nakama.go @@ -0,0 +1,78 @@ +package service + +import ( + "fmt" + "time" + + "github.com/docker/docker/api/types/container" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "pkg.world.dev/world-cli/common/config" +) + +func getNakamaContainerName(cfg *config.Config) string { + return fmt.Sprintf("%s-nakama", cfg.DockerEnv["CARDINAL_NAMESPACE"]) +} + +func Nakama(cfg *config.Config) Service { + // Check cardinal namespace + checkCardinalNamespace(cfg) + + enableAllowList := cfg.DockerEnv["ENABLE_ALLOWLIST"] + if enableAllowList == "" { + enableAllowList = "false" + } + + outgoingQueueSize := cfg.DockerEnv["OUTGOING_QUEUE_SIZE"] + if outgoingQueueSize == "" { + outgoingQueueSize = "64" + } + + // Set default password if not provided + dbPassword := cfg.DockerEnv["DB_PASSWORD"] + if dbPassword == "" { + dbPassword = "very_unsecure_password_please_change" //nolint:gosec // This is a default password + } + + exposedPorts := []int{7349, 7350, 7351} + + return Service{ + Name: getNakamaContainerName(cfg), + Config: container.Config{ + Image: "ghcr.io/argus-labs/world-engine-nakama:1.2.7", + Env: []string{ + fmt.Sprintf("CARDINAL_CONTAINER=%s", getCardinalContainerName(cfg)), + fmt.Sprintf("CARDINAL_ADDR=%s:4040", getCardinalContainerName(cfg)), + fmt.Sprintf("CARDINAL_NAMESPACE=%s", cfg.DockerEnv["CARDINAL_NAMESPACE"]), + fmt.Sprintf("DB_PASSWORD=%s", dbPassword), + fmt.Sprintf("ENABLE_ALLOWLIST=%s", enableAllowList), + fmt.Sprintf("OUTGOING_QUEUE_SIZE=%s", outgoingQueueSize), + }, + Entrypoint: []string{ + "/bin/sh", + "-ec", + fmt.Sprintf("/nakama/nakama migrate up --database.address root:%s@%s:26257/nakama && /nakama/nakama --config /nakama/data/local.yml --database.address root:%s@%s:26257/nakama --socket.outgoing_queue_size=64 --logger.level INFO", //nolint:lll + dbPassword, + getNakamaDBContainerName(cfg), + dbPassword, + getNakamaDBContainerName(cfg)), + }, + ExposedPorts: getExposedPorts(exposedPorts), + Healthcheck: &container.HealthConfig{ + Test: []string{"CMD", "/nakama/nakama", "healthcheck"}, + Interval: 1 * time.Second, + Timeout: 1 * time.Second, + Retries: 20, //nolint:gomnd + }, + }, + HostConfig: container.HostConfig{ + PortBindings: newPortMap(exposedPorts), + RestartPolicy: container.RestartPolicy{Name: "unless-stopped"}, + NetworkMode: container.NetworkMode(cfg.DockerEnv["CARDINAL_NAMESPACE"]), + }, + Platform: ocispec.Platform{ + Architecture: "amd64", + OS: "linux", + }, + } +} diff --git a/common/docker/service/nakamadb.go b/common/docker/service/nakamadb.go new file mode 100644 index 0000000..91544db --- /dev/null +++ b/common/docker/service/nakamadb.go @@ -0,0 +1,54 @@ +package service + +import ( + "fmt" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/mount" + + "pkg.world.dev/world-cli/common/config" + "pkg.world.dev/world-cli/common/logger" +) + +func getNakamaDBContainerName(cfg *config.Config) string { + return fmt.Sprintf("%s-nakama-db", cfg.DockerEnv["CARDINAL_NAMESPACE"]) +} + +func NakamaDB(cfg *config.Config) Service { + exposedPorts := []int{26257, 8080} + + // Set default password if not provided + dbPassword := cfg.DockerEnv["DB_PASSWORD"] + if dbPassword == "" { + logger.Warn("Using default DB_PASSWORD, please change it.") + dbPassword = "very_unsecure_password_please_change" //nolint:gosec // This is a default password + } + + return Service{ + Name: getNakamaDBContainerName(cfg), + Config: container.Config{ + Image: "cockroachdb/cockroach:latest-v23.1", + Cmd: []string{"start-single-node", "--insecure", "--store=attrs=ssd,path=/var/lib/cockroach/,size=20%"}, + Env: []string{ + "COCKROACH_DATABASE=nakama", + "COCKROACH_USER=root", + fmt.Sprintf("COCKROACH_PASSWORD=%s", dbPassword), + }, + ExposedPorts: getExposedPorts(exposedPorts), + Healthcheck: &container.HealthConfig{ + Test: []string{"CMD", "curl", "-f", "http://localhost:8080/health?ready=1"}, + Interval: 3 * time.Second, //nolint:gomnd + Timeout: 3 * time.Second, //nolint:gomnd + Retries: 5, //nolint:gomnd + }, + }, + HostConfig: container.HostConfig{ + PortBindings: newPortMap(exposedPorts), + RestartPolicy: container.RestartPolicy{Name: "unless-stopped"}, + Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: cfg.DockerEnv["CARDINAL_NAMESPACE"], + Target: "/var/lib/cockroach"}}, + NetworkMode: container.NetworkMode(cfg.DockerEnv["CARDINAL_NAMESPACE"]), + }, + } +} diff --git a/common/docker/service/redis.go b/common/docker/service/redis.go new file mode 100644 index 0000000..ad03df3 --- /dev/null +++ b/common/docker/service/redis.go @@ -0,0 +1,50 @@ +package service + +import ( + "fmt" + "strconv" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/mount" + + "pkg.world.dev/world-cli/common/config" + "pkg.world.dev/world-cli/common/logger" +) + +func getRedisContainerName(cfg *config.Config) string { + return fmt.Sprintf("%s-redis", cfg.DockerEnv["CARDINAL_NAMESPACE"]) +} + +func Redis(cfg *config.Config) Service { + // Check cardinal namespace + checkCardinalNamespace(cfg) + + redisPort := cfg.DockerEnv["REDIS_PORT"] + if redisPort == "" { + redisPort = "6379" + } + + intPort, err := strconv.Atoi(redisPort) + if err != nil { + logger.Error("Failed to convert redis port to int, defaulting to 6379", err) + intPort = 6379 + } + exposedPorts := []int{intPort} + + return Service{ + Name: getRedisContainerName(cfg), + Config: container.Config{ + Image: "redis:latest", + Env: []string{ + fmt.Sprintf("REDIS_PASSWORD=%s", cfg.DockerEnv["REDIS_PASSWORD"]), + }, + ExposedPorts: getExposedPorts(exposedPorts), + }, + HostConfig: container.HostConfig{ + PortBindings: newPortMap(exposedPorts), + RestartPolicy: container.RestartPolicy{Name: "unless-stopped"}, + Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: "data", Target: "/redis"}}, + NetworkMode: container.NetworkMode(cfg.DockerEnv["CARDINAL_NAMESPACE"]), + }, + } +} diff --git a/common/docker/service/service.go b/common/docker/service/service.go new file mode 100644 index 0000000..e1bcb80 --- /dev/null +++ b/common/docker/service/service.go @@ -0,0 +1,71 @@ +package service + +import ( + "fmt" + "strconv" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/docker/go-connections/nat" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "pkg.world.dev/world-cli/common/config" + "pkg.world.dev/world-cli/common/logger" +) + +var ( + // BuildkitSupport is a flag to check if buildkit is supported + BuildkitSupport bool +) + +type Builder func(cfg *config.Config) Service + +// Service is a configuration for a docker container +// It contains the name of the container and a function to get the container and host config +type Service struct { + Name string + container.Config + container.HostConfig + network.NetworkingConfig + ocispec.Platform + + // Dependencies are other services that need to be pull before this service + Dependencies []Service + // Dockerfile is the content of the Dockerfile + Dockerfile string + // BuildTarget is the target build of the Dockerfile e.g. builder or runtime + BuildTarget string +} + +func getExposedPorts(ports []int) nat.PortSet { + exposedPorts := make(nat.PortSet) + for _, port := range ports { + if port < 1 || port > 65535 { + panic(fmt.Sprintf("invalid port %d, must be between 1 and 65535", port)) + } + tcpPort := nat.Port(strconv.Itoa(port) + "/tcp") + exposedPorts[tcpPort] = struct{}{} + } + return exposedPorts +} + +func newPortMap(ports []int) nat.PortMap { + portMap := make(nat.PortMap) + for _, port := range ports { + if port < 1 || port > 65535 { + panic(fmt.Sprintf("invalid port %d, must be between 1 and 65535", port)) + } + portStr := strconv.Itoa(port) + tcpPort := nat.Port(portStr + "/tcp") + portMap[tcpPort] = []nat.PortBinding{{HostPort: portStr}} + } + return portMap +} + +func checkCardinalNamespace(cfg *config.Config) { + if cfg.DockerEnv["CARDINAL_NAMESPACE"] == "" { + // Set default namespace if not provided + logger.Warn("CARDINAL_NAMESPACE not provided, defaulting to defaultnamespace") + cfg.DockerEnv["CARDINAL_NAMESPACE"] = "defaultnamespace" + } +} diff --git a/go.mod b/go.mod index d66e100..3614f44 100644 --- a/go.mod +++ b/go.mod @@ -8,32 +8,66 @@ require ( github.com/charmbracelet/bubbles v0.18.0 github.com/charmbracelet/bubbletea v0.26.2 github.com/denisbrodbeck/machineid v1.0.1 + github.com/docker/docker v27.1.1+incompatible + github.com/docker/go-connections v0.5.0 github.com/getsentry/sentry-go v0.27.0 github.com/google/uuid v1.6.0 github.com/guumaster/logsymbols v0.3.1 github.com/hashicorp/go-version v1.6.0 github.com/magefile/mage v1.15.0 + github.com/moby/buildkit v0.15.2 + github.com/opencontainers/image-spec v1.1.0 github.com/pelletier/go-toml v1.9.5 github.com/pelletier/go-toml/v2 v2.2.2 - github.com/pkg/errors v0.9.1 github.com/posthog/posthog-go v0.0.0-20240202122501-d793288ce2c9 github.com/rotisserie/eris v0.5.4 github.com/rs/zerolog v1.31.0 github.com/spf13/cobra v1.8.0 github.com/stretchr/testify v1.9.0 + github.com/vbauerster/mpb/v8 v8.8.2 golang.org/x/mod v0.17.0 gotest.tools/v3 v3.5.1 ) require ( + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/VividCortex/ewma v1.2.0 // indirect + github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-errors/errors v1.5.1 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gogo/googleapis v1.4.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.6.0 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect + go.opentelemetry.io/otel v1.28.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0 // indirect + go.opentelemetry.io/otel/metric v1.28.0 // indirect + go.opentelemetry.io/otel/sdk v1.28.0 // indirect + go.opentelemetry.io/otel/trace v1.28.0 // indirect + golang.org/x/net v0.26.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect + google.golang.org/grpc v1.64.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) @@ -44,7 +78,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 @@ -52,8 +86,8 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.5 // indirect golang.org/x/sync v0.7.0 - golang.org/x/sys v0.20.0 // indirect - golang.org/x/term v0.20.0 - golang.org/x/text v0.15.0 // indirect + golang.org/x/sys v0.24.0 // indirect + golang.org/x/term v0.21.0 + golang.org/x/text v0.16.0 // indirect gopkg.in/gookit/color.v1 v1.1.6 // indirect ) diff --git a/go.sum b/go.sum index 361ab17..ec5072a 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,18 @@ +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= +github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= +github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= +github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= 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= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= github.com/charmbracelet/bubbletea v0.26.2 h1:Eeb+n75Om9gQ+I6YpbCXQRKHt5Pn4vMwusQpwLiEgJQ= @@ -11,33 +21,70 @@ github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ= github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY= +github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/googleapis v1.4.1 h1:1Yx4Myt7BxzvUr5ldGSbwYiZG6t9wGBZ+8/fX3Wvtq0= +github.com/gogo/googleapis v1.4.1/go.mod h1:2lpHqI5OcWCtVElxXnPt+s8oJvMpySlOyM6xDCrzib4= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= github.com/guumaster/logsymbols v0.3.1 h1:bnCE484dAQFvMWt2EfZAzF1oCgu8yo/Vp1QGQ0EmaAA= 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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= @@ -52,8 +99,16 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/moby/buildkit v0.15.2 h1:DnONr0AoceTWyv+plsQ7IhkSaj+6o0WyoaxYPyTFIxs= +github.com/moby/buildkit v0.15.2/go.mod h1:Yis8ZMUJTHX9XhH9zVyK2igqSHV3sxi3UN0uztZocZk= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -62,12 +117,17 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= 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/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -79,6 +139,9 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rotisserie/eris v0.5.4 h1:Il6IvLdAapsMhvuOahHWiBnl1G++Q0/L5UIkI5mARSk= github.com/rotisserie/eris v0.5.4/go.mod h1:Z/kgYTJiJtocxCbFfvRmO+QejApzG6zpyky9G1A4g9s= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= @@ -87,6 +150,8 @@ github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWR github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -102,23 +167,83 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/vbauerster/mpb/v8 v8.8.2 h1:j9D/WmvKZw0BK1etRkw8lxVMKs4KO3TgdXsQWyEyPuc= +github.com/vbauerster/mpb/v8 v8.8.2/go.mod h1:JfCCrtcMsJwP6ZwMn9e5LMnNyp3TVNpUWWkN+nd4EWk= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg= +go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= +go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0 h1:j9+03ymgYhPKmeXGk5Zu+cIZOlVzd9Zv7QIiyItjFBU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0/go.mod h1:Y5+XiUG4Emn1hTfciPzGPJaSI+RpDts6BnCIir0SLqk= +go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q= +go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s= +go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE= +go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg= +go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g= +go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b h1:+YaDE2r2OG8t/z5qmsh7Y+XXwCbvadxxZ0YY6mTdrVA= +google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 h1:0+ozOGcrp+Y8Aq8TLNN2Aliibms5LEzsq99ZZmAGYm0= +google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094/go.mod h1:fJ/e3If/Q67Mj99hin0hMhiNyCRmt6BQ2aWIJshUSJw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1:BwIjyKYGsK9dMCBOorzRri8MQwmi7mT9rGHsCEinZkA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= +google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= +google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/gookit/color.v1 v1.1.6 h1:5fB10p6AUFjhd2ayq9JgmJWr9WlTrguFdw3qlYtKNHk= gopkg.in/gookit/color.v1 v1.1.6/go.mod h1:IcEkFGaveVShJ+j8ew+jwe9epHyGpJ9IrptHmW3laVY= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/makefiles/lint.mk b/makefiles/lint.mk index b33716d..1cda4b0 100644 --- a/makefiles/lint.mk +++ b/makefiles/lint.mk @@ -2,12 +2,14 @@ lint_version=v1.56.2 lint-install: @echo "--> Checking if golangci-lint $(lint_version) is installed" - @if [ $$(golangci-lint --version 2> /dev/null | awk '{print $$4}') != "$(lint_version)" ]; then \ + @installed_version=$$(golangci-lint --version 2> /dev/null | awk '{print $$4}') || true; \ + if [ "$$installed_version" != "$(lint_version)" ]; then \ echo "--> Installing golangci-lint $(lint_version)"; \ go install github.com/golangci/golangci-lint/cmd/golangci-lint@$(lint_version); \ else \ echo "--> golangci-lint $(lint_version) is already installed"; \ fi + lint: @$(MAKE) lint-install @echo "--> Running linter" diff --git a/tea/component/spinner/spinner.go b/tea/component/spinner/spinner.go new file mode 100644 index 0000000..4c9ed7a --- /dev/null +++ b/tea/component/spinner/spinner.go @@ -0,0 +1,61 @@ +package teaspinner + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" +) + +// Spinner is a component that displays a spinner while updating the logs +type Spinner struct { + Spinner spinner.Model + Cancel func() + + text string + done bool +} + +type LogMsg string + +// Init is called when the program starts and returns the initial command +func (s Spinner) Init() tea.Cmd { + // Start the spinner + return s.Spinner.Tick +} + +// Update handles incoming messages +func (s Spinner) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + // case ctrl + c + if msg.String() == "ctrl+c" { + s.Cancel() + return s, tea.Quit + } + case spinner.TickMsg: + // Update the spinner + var cmd tea.Cmd + s.Spinner, cmd = s.Spinner.Update(msg) + return s, cmd + case LogMsg: + // Add the log message to the list of logs and return a spinner tick + s.text = string(msg) + if string(msg) == "spin: completed" { + s.done = true + return s, tea.Quit + } + return s, s.Spinner.Tick + } + + return s, nil +} + +// View renders the UI +func (s Spinner) View() string { + if s.done { + return "Build completed!" + } + + return fmt.Sprintf("%s %s", s.Spinner.View(), s.text) +}