diff --git a/cmd/world/cardinal/cardinal.go b/cmd/world/cardinal/cardinal.go index 2136326..fbeeb7f 100644 --- a/cmd/world/cardinal/cardinal.go +++ b/cmd/world/cardinal/cardinal.go @@ -20,7 +20,6 @@ var BaseCmd = &cobra.Command{ dependency.Go, dependency.Git, dependency.Docker, - dependency.DockerCompose, dependency.DockerDaemon, ) }, diff --git a/cmd/world/cardinal/dev.go b/cmd/world/cardinal/dev.go index 91a9f0c..0583626 100644 --- a/cmd/world/cardinal/dev.go +++ b/cmd/world/cardinal/dev.go @@ -132,20 +132,26 @@ func startCardinalDevMode(ctx context.Context, cfg *config.Config, prettyLog boo // Check and wait until Redis is running and is available in the expected port isRedisHealthy := false for !isRedisHealthy { - redisAddress := fmt.Sprintf("localhost:%s", RedisPort) - conn, err := net.DialTimeout("tcp", redisAddress, time.Second) - if err != nil { - logger.Printf("Failed to connect to Redis at %s: %s\n", redisAddress, err) - time.Sleep(1 * time.Second) - continue - } + // using select to allow for context cancellation + select { + case <-ctx.Done(): + return eris.Wrap(ctx.Err(), "Context canceled") + default: + redisAddress := fmt.Sprintf("localhost:%s", RedisPort) + conn, err := net.DialTimeout("tcp", redisAddress, time.Second) + if err != nil { + logger.Printf("Failed to connect to Redis at %s: %s\n", redisAddress, err) + time.Sleep(1 * time.Second) + continue + } - // Cleanup connection - if err := conn.Close(); err != nil { - continue - } + // Cleanup connection + if err := conn.Close(); err != nil { + continue + } - isRedisHealthy = true + isRedisHealthy = true + } } // Move into the cardinal directory @@ -236,10 +242,14 @@ func startRedis(ctx context.Context, cfg *config.Config) error { } defer dockerClient.Close() + // Create context with cancel + ctx, cancel := context.WithCancel(ctx) + // Start Redis container group.Go(func() error { cfg.Detach = true - if err := dockerClient.Start(ctx, cfg, service.Redis); err != nil { + if err := dockerClient.Start(ctx, service.Redis); err != nil { + cancel() return eris.Wrap(err, "Encountered an error with Redis") } return nil @@ -251,7 +261,8 @@ 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 := dockerClient.Stop(cfg, service.Redis); err != nil { + // Using context background because cmd context is already done + if err := dockerClient.Stop(context.Background(), service.Redis); err != nil { return err } return nil diff --git a/cmd/world/cardinal/purge.go b/cmd/world/cardinal/purge.go index 228651a..f123973 100644 --- a/cmd/world/cardinal/purge.go +++ b/cmd/world/cardinal/purge.go @@ -34,7 +34,7 @@ This command stop all Docker services and remove all Docker volumes.`, } defer dockerClient.Close() - err = dockerClient.Purge(cfg, service.Nakama, service.Cardinal, service.NakamaDB, service.Redis) + err = dockerClient.Purge(cmd.Context(), 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 7d829a9..8222f0b 100644 --- a/cmd/world/cardinal/restart.go +++ b/cmd/world/cardinal/restart.go @@ -39,7 +39,7 @@ This will restart the following Docker services: } defer dockerClient.Close() - err = dockerClient.Restart(cmd.Context(), cfg, service.Cardinal, service.Nakama) + err = dockerClient.Restart(cmd.Context(), service.Cardinal, service.Nakama) if err != nil { return err } diff --git a/cmd/world/cardinal/start.go b/cmd/world/cardinal/start.go index 838e315..293ee38 100644 --- a/cmd/world/cardinal/start.go +++ b/cmd/world/cardinal/start.go @@ -120,7 +120,7 @@ This will start the following Docker services and its dependencies: // Start the World Engine stack group.Go(func() error { - if err := dockerClient.Start(ctx, cfg, service.NakamaDB, + if err := dockerClient.Start(ctx, service.NakamaDB, service.Redis, service.Cardinal, service.Nakama); err != nil { return eris.Wrap(err, "Encountered an error with Docker") } diff --git a/cmd/world/cardinal/stop.go b/cmd/world/cardinal/stop.go index f1de6b0..9a559f8 100644 --- a/cmd/world/cardinal/stop.go +++ b/cmd/world/cardinal/stop.go @@ -38,7 +38,7 @@ This will stop the following Docker services: } defer dockerClient.Close() - err = dockerClient.Stop(cfg, service.Nakama, service.Cardinal, service.NakamaDB, service.Redis) + err = dockerClient.Stop(cmd.Context(), 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 f209401..d6f9c23 100644 --- a/cmd/world/evm/start.go +++ b/cmd/world/evm/start.go @@ -50,14 +50,15 @@ var startCmd = &cobra.Command{ cfg.Detach = false cfg.Timeout = 0 - err = dockerClient.Start(cmd.Context(), cfg, service.EVM) + err = dockerClient.Start(cmd.Context(), 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) + // using context background because cmd.Context() is already done + err = dockerClient.Stop(context.Background(), service.CelestiaDevNet) if err != nil { return eris.Wrap(err, "Failed to stop DA service") } @@ -80,7 +81,7 @@ func validateDevDALayer(ctx context.Context, cfg *config.Config, dockerClient *d cfg.Detach = true cfg.Timeout = -1 logger.Println("starting DA docker service for dev mode...") - if err := dockerClient.Start(ctx, cfg, service.CelestiaDevNet); err != nil { + if err := dockerClient.Start(ctx, service.CelestiaDevNet); err != nil { return fmt.Errorf("error starting %s docker container: %w", daService, err) } logger.Println("started DA service...") diff --git a/cmd/world/evm/stop.go b/cmd/world/evm/stop.go index 3afb571..ba43c93 100644 --- a/cmd/world/evm/stop.go +++ b/cmd/world/evm/stop.go @@ -27,7 +27,7 @@ var stopCmd = &cobra.Command{ } defer dockerClient.Close() - err = dockerClient.Stop(cfg, service.EVM, service.CelestiaDevNet) + err = dockerClient.Stop(cmd.Context(), service.EVM, service.CelestiaDevNet) if err != nil { return err } diff --git a/common/docker/client.go b/common/docker/client.go index 2a13ad8..037cd15 100644 --- a/common/docker/client.go +++ b/common/docker/client.go @@ -39,18 +39,18 @@ func (c *Client) Close() error { return c.client.Close() } -func (c *Client) Start(ctx context.Context, cfg *config.Config, +func (c *Client) Start(ctx context.Context, serviceBuilders ...service.Builder) error { defer func() { - if !cfg.Detach { - err := c.Stop(cfg, serviceBuilders...) + if !c.cfg.Detach { + err := c.Stop(context.Background(), serviceBuilders...) if err != nil { logger.Error("Failed to stop containers", err) } } }() - namespace := cfg.DockerEnv["CARDINAL_NAMESPACE"] + namespace := c.cfg.DockerEnv["CARDINAL_NAMESPACE"] err := c.createNetworkIfNotExists(ctx, namespace) if err != nil { return eris.Wrap(err, "Failed to create network") @@ -64,7 +64,8 @@ func (c *Client) Start(ctx context.Context, cfg *config.Config, // get all services dockerServices := make([]service.Service, 0) for _, sb := range serviceBuilders { - dockerServices = append(dockerServices, sb(cfg)) + ds := sb(c.cfg) + dockerServices = append(dockerServices, ds) } // Pull all images before starting containers @@ -73,51 +74,60 @@ func (c *Client) Start(ctx context.Context, cfg *config.Config, 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") - } + // Build all images before starting containers + if c.cfg.Build { + err = c.buildImages(ctx, dockerServices...) + if err != nil { + return eris.Wrap(err, "Failed to build images") } + } - // create container & start - if err := c.startContainer(ctx, dockerService); err != nil { - return eris.Wrap(err, "Failed to create container") - } + // Start all containers + err = c.processMultipleContainers(ctx, START, dockerServices...) + if err != nil { + return eris.Wrap(err, "Failed to start containers") } // log containers if not detached - if !cfg.Detach { + if !c.cfg.Detach { c.logMultipleContainers(ctx, dockerServices...) } return nil } -func (c *Client) Stop(cfg *config.Config, serviceBuilders ...service.Builder) error { - ctx := context.Background() +func (c *Client) Stop(ctx context.Context, serviceBuilders ...service.Builder) error { + // get all services + dockerServices := make([]service.Service, 0) for _, sb := range serviceBuilders { - dockerService := sb(cfg) - if err := c.stopContainer(ctx, dockerService.Name); err != nil { - return eris.Wrap(err, "Failed to stop container") - } + ds := sb(c.cfg) + dockerServices = append(dockerServices, ds) + } + + // Stop all containers + err := c.processMultipleContainers(ctx, STOP, dockerServices...) + if err != nil { + return eris.Wrap(err, "Failed to stop containers") } return nil } -func (c *Client) Purge(cfg *config.Config, serviceBuilders ...service.Builder) error { - ctx := context.Background() +func (c *Client) Purge(ctx context.Context, serviceBuilders ...service.Builder) error { + // get all services + dockerServices := make([]service.Service, 0) for _, sb := range serviceBuilders { - dockerService := sb(cfg) - if err := c.removeContainer(ctx, dockerService.Name); err != nil { - return eris.Wrap(err, "Failed to remove container") - } + ds := sb(c.cfg) + dockerServices = append(dockerServices, ds) + } + + // remove all containers + err := c.processMultipleContainers(ctx, REMOVE, dockerServices...) + if err != nil { + return eris.Wrap(err, "Failed to remove containers") } - err := c.removeVolume(ctx, cfg.DockerEnv["CARDINAL_NAMESPACE"]) + err = c.removeVolume(ctx, c.cfg.DockerEnv["CARDINAL_NAMESPACE"]) if err != nil { return err } @@ -125,15 +135,15 @@ func (c *Client) Purge(cfg *config.Config, serviceBuilders ...service.Builder) e return nil } -func (c *Client) Restart(ctx context.Context, cfg *config.Config, +func (c *Client) Restart(ctx context.Context, serviceBuilders ...service.Builder) error { // stop containers - err := c.Stop(cfg, serviceBuilders...) + err := c.Stop(ctx, serviceBuilders...) if err != nil { return err } - return c.Start(ctx, cfg, serviceBuilders...) + return c.Start(ctx, serviceBuilders...) } func (c *Client) Exec(ctx context.Context, containerID string, cmd []string) (string, error) { diff --git a/common/docker/client_container.go b/common/docker/client_container.go index d4c948c..f546614 100644 --- a/common/docker/client_container.go +++ b/common/docker/client_container.go @@ -1,25 +1,138 @@ package docker import ( + "bufio" "context" + "encoding/binary" "errors" "fmt" "io" + "regexp" "strconv" "sync" "time" + tea "github.com/charmbracelet/bubbletea" "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/component/multispinner" "pkg.world.dev/world-cli/tea/style" ) -func (c *Client) startContainer(ctx context.Context, service service.Service) error { - contextPrint("Starting", "2", "container", service.Name) +const ( + START processType = iota + STOP + REMOVE +) + +type processType int + +func (c *Client) processMultipleContainers(ctx context.Context, processType processType, + services ...service.Service) error { + // Collect the names of the services + dockerServicesNames := make([]string, len(services)) + for i, dockerService := range services { + dockerServicesNames[i] = dockerService.Name + } + + // Create context with cancel + ctx, cancel := context.WithCancel(ctx) + + // Channel to collect errors from the goroutines + errChan := make(chan error, len(dockerServicesNames)) + + // Create tea program for multispinner + p := tea.NewProgram(multispinner.CreateSpinner(dockerServicesNames, cancel)) + + var ( + startState string + finishState string + ) + + switch processType { + case STOP: + startState = "stopping" + finishState = "stopped" + case REMOVE: + startState = "removing" + finishState = "removed" + case START: + startState = "starting" + finishState = "started" + } + + // Process all containers + for _, dockerService := range services { + // capture the dockerService + dockerService := dockerService + + go func() { + p.Send(multispinner.ProcessState{ + Icon: style.CrossIcon.Render(), + Type: "container", + Name: dockerService.Name, + State: startState, + }) + + // call the respective function based on the process type + var err error + switch processType { + case STOP: + err = c.stopContainer(ctx, dockerService.Name) + case REMOVE: + err = c.removeContainer(ctx, dockerService.Name) + case START: + err = c.startContainer(ctx, dockerService) + } + + if err != nil { + p.Send(multispinner.ProcessState{ + Icon: style.CrossIcon.Render(), + Type: "container", + Name: dockerService.Name, + State: startState, + Detail: err.Error(), + Done: true, + }) + errChan <- err + return + } + + // if no error, send success + p.Send(multispinner.ProcessState{ + Icon: style.TickIcon.Render(), + Type: "container", + Name: dockerService.Name, + State: finishState, + Done: true, + }) + }() + } + + // Run the program + if _, err := p.Run(); err != nil { + return eris.Wrap(err, "Failed to run multispinner") + } + + // 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.New(fmt.Sprintf("Errors: %v", errs)) + } + + return nil +} + +func (c *Client) startContainer(ctx context.Context, service service.Service) error { // Check if the container exists exist, err := c.containerExists(ctx, service.Name) if err != nil { @@ -29,18 +142,15 @@ func (c *Client) startContainer(ctx context.Context, service service.Service) er _, 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 } @@ -57,51 +167,40 @@ func (c *Client) containerExists(ctx context.Context, containerName string) (boo } 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 } @@ -116,7 +215,6 @@ func (c *Client) logMultipleContainers(ctx context.Context, services ...service. 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)) @@ -133,7 +231,7 @@ func (c *Client) logMultipleContainers(ctx context.Context, services ...service. wg.Wait() } -func (c *Client) logContainerOutput(ctx context.Context, containerID, style string) error { +func (c *Client) logContainerOutput(ctx context.Context, containerID, styleNumber string) error { // Create options for logs options := container.LogsOptions{ ShowStdout: true, @@ -148,20 +246,54 @@ func (c *Client) logContainerOutput(ctx context.Context, containerID, style stri } defer out.Close() - // Print logs - buf := make([]byte, 4096) //nolint:gomnd + reader := bufio.NewReader(out) for { - n, err := out.Read(buf) - if n > 0 { - fmt.Printf("[%s] %s", foregroundPrint(containerID, style), buf[:n]) + // Read the 8-byte header + header := make([]byte, 8) //nolint:gomnd // 8 bytes + if _, err := io.ReadFull(reader, header); err != nil { + if err == io.EOF { + break + } + return err } - if err != nil { + + // Determine the stream type from the first byte + streamType := header[0] + // Get the size of the log payload from the last 4 bytes + size := binary.BigEndian.Uint32(header[4:]) + + // Read the log payload based on the size + payload := make([]byte, size) + if _, err := io.ReadFull(reader, payload); err != nil { if err == io.EOF { break } return err } + + // Clean the log message by removing ANSI escape codes + cleanLog := removeFirstAnsiEscapeCode(string(payload)) + + // Print the cleaned log message + if streamType == 1 { // Stdout + fmt.Printf("[%s] %s", style.ForegroundPrint(containerID, styleNumber), cleanLog) + } else if streamType == 2 { //nolint:gomnd // Stderr + fmt.Printf("[%s] %s", style.ForegroundPrint(containerID, styleNumber), cleanLog) + } } return nil } + +// Function to remove only the first ANSI escape code from a string +func removeFirstAnsiEscapeCode(input string) string { + ansiEscapePattern := regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) + + loc := ansiEscapePattern.FindStringIndex(input) // Find the first occurrence of an ANSI escape code + if loc == nil { + return input // If no ANSI escape code is found, return the input as-is + } + + // Remove the first ANSI escape code by slicing out the matched part + return input[:loc[0]] + input[loc[1]:] +} diff --git a/common/docker/client_image.go b/common/docker/client_image.go index ede5b06..76690e6 100644 --- a/common/docker/client_image.go +++ b/common/docker/client_image.go @@ -13,9 +13,7 @@ import ( "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" @@ -25,13 +23,91 @@ import ( "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" + "pkg.world.dev/world-cli/tea/component/multispinner" + "pkg.world.dev/world-cli/tea/style" ) -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 +func (c *Client) buildImages(ctx context.Context, dockerServices ...service.Service) error { + // Filter all services that need to be built + var ( + serviceToBuild []service.Service + imagesName []string + ) + for _, dockerService := range dockerServices { + if dockerService.Dockerfile != "" { + serviceToBuild = append(serviceToBuild, dockerService) + imagesName = append(imagesName, dockerService.Image) + } + } + + if len(serviceToBuild) == 0 { + return nil + } + + // Create ctx with cancel + ctx, cancel := context.WithCancel(ctx) + + // Channel to collect errors from the goroutines + errChan := make(chan error, len(imagesName)) + + p := tea.NewProgram(multispinner.CreateSpinner(imagesName, cancel)) + + for _, dockerService := range serviceToBuild { + // Capture dockerService in the loop + dockerService := dockerService + + go func() { + p.Send(multispinner.ProcessState{ + State: "building", + Type: "image", + Name: dockerService.Image, + }) + + // Start the build process + buildResponse, err := c.buildImage(ctx, dockerService) + if err != nil { + p.Send(multispinner.ProcessState{ + Icon: style.CrossIcon.Render(), + State: "building", + Type: "image", + Name: dockerService.Image, + Detail: err.Error(), + Done: true, + }) + errChan <- err + return + } + defer buildResponse.Body.Close() + + // Print the build logs + err = c.readBuildLog(ctx, buildResponse.Body, p, dockerService.Image) + if err != nil { + errChan <- err + } + }() + } + + // Run the program + if _, err := p.Run(); err != nil { + return eris.Wrap(err, "Error running program") + } + + // 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.New(fmt.Sprintf("Errors: %v", errs)) + } + + return nil +} + +func (c *Client) buildImage(ctx context.Context, dockerService service.Service) (*types.ImageBuildResponse, error) { buf := new(bytes.Buffer) tw := tar.NewWriter(buf) defer tw.Close() @@ -39,18 +115,18 @@ func (c *Client) buildImage(ctx context.Context, dockerfile, target, imageName s // Add the Dockerfile to the tar archive header := &tar.Header{ Name: "Dockerfile", - Size: int64(len(dockerfile)), + Size: int64(len(dockerService.Dockerfile)), } if err := tw.WriteHeader(header); err != nil { - return eris.Wrap(err, "Failed to write header to tar writer") + return nil, 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") + if _, err := tw.Write([]byte(dockerService.Dockerfile)); err != nil { + return nil, 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") + return nil, eris.Wrap(err, "Failed to add source code to tar writer") } // Read the tar archive @@ -58,8 +134,8 @@ func (c *Client) buildImage(ctx context.Context, dockerfile, target, imageName s buildOptions := types.ImageBuildOptions{ Dockerfile: "Dockerfile", - Tags: []string{imageName}, - Target: target, + Tags: []string{dockerService.Image}, + Target: dockerService.BuildTarget, } if service.BuildkitSupport { @@ -69,14 +145,10 @@ func (c *Client) buildImage(ctx context.Context, dockerfile, target, imageName s // Build the image buildResponse, err := c.client.ImageBuild(ctx, tarReader, buildOptions) if err != nil { - return err + return nil, eris.Wrap(err, "Failed to build image") } - defer buildResponse.Body.Close() - - // Print the build logs - c.readBuildLog(ctx, buildResponse.Body) - return nil + return &buildResponse, nil } // AddFileToTarWriter adds a file or directory to the tar writer @@ -139,94 +211,101 @@ func (c *Client) addFileToTarWriter(baseDir string, tw *tar.Writer) error { // - 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) - +// - moby.buildkit.trace: Output from the build process +// - error: Need to research how buildkit log send error messages (TODO) +func (c *Client) readBuildLog(ctx context.Context, reader io.Reader, p *tea.Program, imageName string) error { // 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, - } + for stop := false; !stop; { + select { + case <-ctx.Done(): + stop = true + default: + var step string + var err error + if service.BuildkitSupport { + // Parse the buildkit response + step, err = c.parseBuildkitResp(decoder, &stop) + } else { + // Parse the non-buildkit response + step, err = c.parseNonBuildkitResp(decoder, &stop) + } - // 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 err != nil { + p.Send(multispinner.ProcessState{ + Icon: style.CrossIcon.Render(), + State: "building", + Type: "image", + Name: imageName, + Detail: err.Error(), + Done: true, + }) + return err + } - // Send the step to the spinner - if step != "" { - p.Send(teaspinner.LogMsg(step)) - } + if step != "" { + p.Send(multispinner.ProcessState{ + State: "building", + Type: "image", + Name: imageName, + Detail: 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) } + + // Send the final message to the spinner + p.Send(multispinner.ProcessState{ + Icon: style.TickIcon.Render(), + State: "built", + Type: "image", + Name: imageName, + Done: true, + }) + + return nil } -func (c *Client) parseBuildkitResp(decoder *json.Decoder, stop *bool) string { +func (c *Client) parseBuildkitResp(decoder *json.Decoder, stop *bool) (string, error) { 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) + return "", eris.Wrap(err, "Error decoding build output") } var resp controlapi.StatusResponse if msg.ID != "moby.buildkit.trace" { - return "" + return "", nil } var dt []byte // ignoring all messages that are not understood + // need to research how buildkit log send error messages if err := json.Unmarshal(*msg.Aux, &dt); err != nil { - return "" + return "", nil //nolint:nilerr // ignore error } if err := (&resp).Unmarshal(dt); err != nil { - return "" + return "", nil //nolint:nilerr // ignore error } if len(resp.Vertexes) == 0 { - return "" + return "", nil } // return the name of the vertex (step) that is currently being executed - return resp.Vertexes[len(resp.Vertexes)-1].Name + return resp.Vertexes[len(resp.Vertexes)-1].Name, nil } -func (c *Client) parseNonBuildkitResp(decoder *json.Decoder, stop *bool) string { +func (c *Client) parseNonBuildkitResp(decoder *json.Decoder, stop *bool) (string, error) { 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) + return "", eris.Wrap(err, "Error decoding build output") } // Only show the step if it's a build step @@ -236,10 +315,10 @@ func (c *Client) parseNonBuildkitResp(decoder *json.Decoder, stop *bool) string step = strings.TrimSpace(step) } } else if val, ok = event["error"]; ok && val != "" { - logger.Errorf("Error building image: %v", val) + return "", eris.New(val.(string)) } - return step + return step, nil } // filterImages filters the images that need to be pulled @@ -299,14 +378,9 @@ func (c *Client) pullImages(ctx context.Context, services ...service.Service) er // Create a new progress bar for this image bar := p.AddBar(100, //nolint:gomnd mpb.PrependDecorators( - decor.Name(fmt.Sprintf("%s %s: ", foregroundPrint("Pulling", "2"), imageName)), + decor.Name(fmt.Sprintf("%s %s: ", style.ForegroundPrint("Pulling", "2"), imageName)), decor.Percentage(decor.WCSyncSpace), ), - mpb.AppendDecorators( - decor.OnComplete( - decor.EwmaETA(decor.ET_STYLE_GO, 30, decor.WCSyncWidth), "done", //nolint:gomnd - ), - ), ) go func() { diff --git a/common/docker/client_network.go b/common/docker/client_network.go index d027d2c..369538b 100644 --- a/common/docker/client_network.go +++ b/common/docker/client_network.go @@ -3,28 +3,84 @@ package docker import ( "context" + tea "github.com/charmbracelet/bubbletea" "github.com/docker/docker/api/types/network" + "github.com/rotisserie/eris" - "pkg.world.dev/world-cli/common/logger" + "pkg.world.dev/world-cli/tea/component/multispinner" + "pkg.world.dev/world-cli/tea/style" ) func (c *Client) createNetworkIfNotExists(ctx context.Context, networkName string) error { - networks, err := c.client.NetworkList(ctx, network.ListOptions{}) - if err != nil { - return err - } + // Create context with cancel + ctx, cancel := context.WithCancel(ctx) + p := tea.NewProgram(multispinner.CreateSpinner([]string{networkName}, cancel)) + + errChan := make(chan error, 1) + + go func() { + p.Send(multispinner.ProcessState{ + State: "creating", + Type: "network", + Name: networkName, + }) + + networks, err := c.client.NetworkList(ctx, network.ListOptions{}) + if err != nil { + p.Send(multispinner.ProcessState{ + Icon: style.CrossIcon.Render(), + Type: "network", + Name: networkName, + State: "creating", + Detail: err.Error(), + Done: true, + }) + errChan <- eris.Wrap(err, "Failed to list networks") + return + } + + networkIsExist := false + for _, network := range networks { + if network.Name == networkName { + networkIsExist = true + } + } - for _, network := range networks { - if network.Name == networkName { - logger.Infof("Network %s already exists", networkName) - return nil + if !networkIsExist { + _, err = c.client.NetworkCreate(ctx, networkName, network.CreateOptions{ + Driver: "bridge", + }) + if err != nil { + p.Send(multispinner.ProcessState{ + Icon: style.CrossIcon.Render(), + Type: "network", + Name: networkName, + State: "creating", + Detail: err.Error(), + Done: true, + }) + errChan <- eris.Wrapf(err, "Failed to create network %s", networkName) + return + } } + + p.Send(multispinner.ProcessState{ + Icon: style.TickIcon.Render(), + Type: "network", + Name: networkName, + State: "created", + Done: true, + }) + }() + + // Run the program + if _, err := p.Run(); err != nil { + return eris.Wrap(err, "Failed to run multispinner") } - _, err = c.client.NetworkCreate(ctx, networkName, network.CreateOptions{ - Driver: "bridge", - }) - if err != nil { + // Close the error channel and check for errors + close(errChan) + if err := <-errChan; err != nil { return err } diff --git a/common/docker/client_test.go b/common/docker/client_test.go index e910519..c378ff7 100644 --- a/common/docker/client_test.go +++ b/common/docker/client_test.go @@ -21,10 +21,6 @@ const ( cardinalNamespace = "test" ) -var ( - dockerClient *Client -) - func TestMain(m *testing.M) { // Purge any existing containers cfg := &config.Config{ @@ -33,15 +29,13 @@ func TestMain(m *testing.M) { }, } - c, err := NewClient(cfg) + dockerClient, 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) + err = dockerClient.Purge(context.Background(), service.Nakama, service.Cardinal, service.Redis, service.NakamaDB) if err != nil { logger.Errorf("Failed to purge containers: %v", err) os.Exit(1) @@ -69,9 +63,12 @@ func TestStart(t *testing.T) { Detach: true, } + dockerClient, err := NewClient(cfg) + assert.NilError(t, err, "Failed to create docker client") + ctx := context.Background() - assert.NilError(t, dockerClient.Start(ctx, cfg, service.Redis), "failed to start container") - cleanUp(t, cfg) + assert.NilError(t, dockerClient.Start(ctx, service.Redis), "failed to start container") + cleanUp(t, dockerClient) // Test if the container is running assert.Assert(t, redislIsUp(t)) @@ -87,11 +84,14 @@ func TestStop(t *testing.T) { Detach: true, } + dockerClient, err := NewClient(cfg) + assert.NilError(t, err, "Failed to create docker client") + ctx := context.Background() - assert.NilError(t, dockerClient.Start(ctx, cfg, service.Redis), "failed to start container") - cleanUp(t, cfg) + assert.NilError(t, dockerClient.Start(ctx, service.Redis), "failed to start container") + cleanUp(t, dockerClient) - assert.NilError(t, dockerClient.Stop(cfg, service.Redis), "failed to stop container") + assert.NilError(t, dockerClient.Stop(ctx, service.Redis), "failed to stop container") // Test if the container is stopped assert.Assert(t, redisIsDown(t)) @@ -107,11 +107,14 @@ func TestRestart(t *testing.T) { Detach: true, } + dockerClient, err := NewClient(cfg) + assert.NilError(t, err, "Failed to create docker client") + ctx := context.Background() - assert.NilError(t, dockerClient.Start(ctx, cfg, service.Redis), "failed to start container") - cleanUp(t, cfg) + assert.NilError(t, dockerClient.Start(ctx, service.Redis), "failed to start container") + cleanUp(t, dockerClient) - assert.NilError(t, dockerClient.Restart(ctx, cfg, service.Redis), "failed to restart container") + assert.NilError(t, dockerClient.Restart(ctx, service.Redis), "failed to restart container") // Test if the container is running assert.Assert(t, redislIsUp(t)) @@ -127,9 +130,12 @@ func TestPurge(t *testing.T) { Detach: true, } + dockerClient, err := NewClient(cfg) + assert.NilError(t, err, "Failed to create docker client") + 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") + assert.NilError(t, dockerClient.Start(ctx, service.Redis), "failed to start container") + assert.NilError(t, dockerClient.Purge(ctx, service.Redis), "failed to purge container") // Test if the container is stopped assert.Assert(t, redisIsDown(t)) @@ -144,10 +150,13 @@ func TestStartUndetach(t *testing.T) { }, } + dockerClient, err := NewClient(cfg) + assert.NilError(t, err, "Failed to create docker client") + 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.NilError(t, dockerClient.Start(ctx, service.Redis), "failed to start container") + cleanUp(t, dockerClient) }() assert.Assert(t, redislIsUp(t)) @@ -184,11 +193,14 @@ func TestBuild(t *testing.T) { cardinalService := service.Cardinal(cfg) ctx := context.Background() + dockerClient, err := NewClient(cfg) + assert.NilError(t, err, "Failed to create docker client") + // Pull prerequisite images assert.NilError(t, dockerClient.pullImages(ctx, cardinalService)) // Build the image - err = dockerClient.buildImage(ctx, cardinalService.Dockerfile, cardinalService.BuildTarget, cardinalService.Image) + _, err = dockerClient.buildImage(ctx, cardinalService) assert.NilError(t, err, "Failed to build Docker image") } @@ -224,10 +236,12 @@ func redisIsDown(t *testing.T) bool { return down } -func cleanUp(t *testing.T, cfg *config.Config) { +func cleanUp(t *testing.T, dockerClient *Client) { t.Cleanup(func() { - assert.NilError(t, dockerClient.Purge(cfg, service.Nakama, + assert.NilError(t, dockerClient.Purge(context.Background(), service.Nakama, service.Cardinal, service.Redis, service.NakamaDB), "Failed to purge container during cleanup") + + assert.NilError(t, dockerClient.Close()) }) } diff --git a/common/docker/client_util.go b/common/docker/client_util.go index c403e2e..0d504d0 100644 --- a/common/docker/client_util.go +++ b/common/docker/client_util.go @@ -2,28 +2,14 @@ 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() { diff --git a/common/docker/client_volume.go b/common/docker/client_volume.go index 8943e7e..914bfac 100644 --- a/common/docker/client_volume.go +++ b/common/docker/client_volume.go @@ -2,63 +2,161 @@ package docker import ( "context" - "fmt" + tea "github.com/charmbracelet/bubbletea" "github.com/docker/docker/api/types/volume" "github.com/rotisserie/eris" - "pkg.world.dev/world-cli/common/logger" + "pkg.world.dev/world-cli/tea/component/multispinner" "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 - } + // Create context with cancel + ctx, cancel := context.WithCancel(ctx) + p := tea.NewProgram(multispinner.CreateSpinner([]string{volumeName}, cancel)) + + errChan := make(chan error, 1) + + go func() { + p.Send(multispinner.ProcessState{ + State: "creating", + Type: "volume", + Name: volumeName, + }) + + volumes, err := c.client.VolumeList(ctx, volume.ListOptions{}) + if err != nil { + p.Send(multispinner.ProcessState{ + Icon: style.CrossIcon.Render(), + Type: "volume", + Name: volumeName, + State: "creating", + Detail: err.Error(), + Done: true, + }) + errChan <- eris.Wrap(err, "Failed to list volumes") + return + } - for _, volume := range volumes.Volumes { - if volume.Name == volumeName { - logger.Debugf("Volume %s already exists\n", volumeName) - return nil + volumeIsExist := false + for _, volume := range volumes.Volumes { + if volume.Name == volumeName { + volumeIsExist = true + } } + + if !volumeIsExist { + _, err = c.client.VolumeCreate(ctx, volume.CreateOptions{Name: volumeName}) + if err != nil { + p.Send(multispinner.ProcessState{ + Icon: style.CrossIcon.Render(), + Type: "volume", + Name: volumeName, + State: "creating", + Detail: err.Error(), + Done: true, + }) + errChan <- eris.Wrapf(err, "Failed to create volume %s", volumeName) + return + } + } + + p.Send(multispinner.ProcessState{ + Icon: style.TickIcon.Render(), + Type: "volume", + Name: volumeName, + State: "created", + Done: true, + }) + }() + + // Run the program + if _, err := p.Run(); err != nil { + return eris.Wrap(err, "Failed to run multispinner") } - _, err = c.client.VolumeCreate(ctx, volume.CreateOptions{Name: volumeName}) - if err != nil { + // Close the error channel and check for errors + close(errChan) + if err := <-errChan; 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") - } + // Create context with cancel + ctx, cancel := context.WithCancel(ctx) + p := tea.NewProgram(multispinner.CreateSpinner([]string{volumeName}, cancel)) - isExist := false - for _, v := range volumes.Volumes { - if v.Name == volumeName { - isExist = true - break + errChan := make(chan error, 1) + + go func() { + p.Send(multispinner.ProcessState{ + State: "removing", + Type: "volume", + Name: volumeName, + }) + + volumes, err := c.client.VolumeList(ctx, volume.ListOptions{}) + if err != nil { + p.Send(multispinner.ProcessState{ + Icon: style.CrossIcon.Render(), + Type: "volume", + Name: volumeName, + State: "removing", + Detail: err.Error(), + Done: true, + }) + errChan <- eris.Wrap(err, "Failed to list volumes") + return } - } - // Return if volume does not exist - if !isExist { - return nil - } + isExist := false + for _, v := range volumes.Volumes { + if v.Name == volumeName { + isExist = true + break + } + } - contextPrint("Removing", "1", "volume", volumeName) + // Remove the volume if it exists + if isExist { + err = c.client.VolumeRemove(ctx, volumeName, true) + if err != nil { + p.Send(multispinner.ProcessState{ + Icon: style.CrossIcon.Render(), + Type: "volume", + Name: volumeName, + State: "removing", + Detail: err.Error(), + Done: true, + }) + errChan <- eris.Wrapf(err, "Failed to remove volume %s", volumeName) + return + } + } + + p.Send(multispinner.ProcessState{ + Icon: style.TickIcon.Render(), + Type: "volume", + Name: volumeName, + State: "removed", + Done: true, + }) + }() - err = c.client.VolumeRemove(ctx, volumeName, true) - if err != nil { - return eris.Wrapf(err, "Failed to remove volume %s", volumeName) + // Run the program + if _, err := p.Run(); err != nil { + return eris.Wrap(err, "Failed to run multispinner") + } + + // Close the error channel and check for errors + close(errChan) + if err := <-errChan; err != nil { + return err } - fmt.Println(style.TickIcon.Render()) return nil } diff --git a/tea/component/multispinner/multispinner.go b/tea/component/multispinner/multispinner.go new file mode 100644 index 0000000..13ae225 --- /dev/null +++ b/tea/component/multispinner/multispinner.go @@ -0,0 +1,154 @@ +package multispinner + +import ( + "fmt" + "sync" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "pkg.world.dev/world-cli/tea/style" +) + +// Spinner is a component that displays a spinner while updating the logs +type MultiSpinner struct { + processMap *ProcessStateMap // need to be pointer because of the mutex + processList []string // list of process names + + spinner spinner.Model // spinner model + cancel func() // cancel function for context cancellation + allDone bool +} + +type ProcessStateMap struct { + sync.Mutex + value map[string]ProcessState +} + +type ProcessState struct { + Icon string + State string + Type string + Name string + Detail string + Done bool +} + +func CreateSpinner(processList []string, cancel func()) MultiSpinner { + // Initialize the spinner + s := spinner.New() + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("69")) + s.Spinner = spinner.Points + + // Initialize the process map + processMap := &ProcessStateMap{ + value: make(map[string]ProcessState), + } + + // put all processes in the map + for _, process := range processList { + processMap.value[process] = ProcessState{ + Name: process, + } + } + + return MultiSpinner{ + spinner: s, + processList: processList, + processMap: processMap, + cancel: cancel, + } +} + +// Init is called when the program starts and returns the initial command +func (s MultiSpinner) Init() tea.Cmd { + // Start the spinner + return s.spinner.Tick +} + +// Update handles incoming messages +func (s MultiSpinner) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + // case ctrl + c + if msg.String() == "ctrl+c" { + if s.cancel != nil { + s.cancel() + } + return s, tea.Quit + } + case spinner.TickMsg: + // Update the spinner + var cmd tea.Cmd + s.spinner, cmd = s.spinner.Update(msg) + // If all processes are done, quit after the view is updated + if s.allDone { + return s, tea.Batch(cmd, tea.Quit) + } + return s, cmd + case ProcessState: + s.setState(msg.Name, msg) + + // check if all processes are done + allDone := true + for _, state := range s.getStates() { + if !state.Done { + allDone = false + break + } + } + + if allDone { + // Set the flag to indicate all processes are done + s.allDone = true + // Return a spinner tick to update the view one last time before quitting + return s, s.spinner.Tick + } + } + + return s, nil +} + +// View renders the UI +func (s MultiSpinner) View() string { + text := "" + + processStates := s.getStates() + + for _, state := range processStates { + icon := state.Icon + if !state.Done { + icon = s.spinner.View() + } + + text += fmt.Sprintf("%s %s %s %s %s", icon, + style.ForegroundPrint(state.State, "12"), + style.ForegroundPrint(state.Type, "13"), + style.ForegroundPrint(state.Name, "2"), + state.Detail) + + text += "\n" + } + + return text +} + +func (s MultiSpinner) setState(process string, state ProcessState) { + s.processMap.Lock() + defer s.processMap.Unlock() + + s.processMap.value[process] = state +} + +func (s MultiSpinner) getStates() []ProcessState { + s.processMap.Lock() + defer s.processMap.Unlock() + + states := make([]ProcessState, 0, len(s.processMap.value)) + for _, process := range s.processList { + states = append(states, s.processMap.value[process]) + } + + return states +} diff --git a/tea/style/util.go b/tea/style/util.go new file mode 100644 index 0000000..a09e9d9 --- /dev/null +++ b/tea/style/util.go @@ -0,0 +1,19 @@ +package style + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +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) +}