From bfeb32ae1f865168a259c11e5216db90fa375f54 Mon Sep 17 00:00:00 2001 From: Reece Williams <31943163+Reecepbcups@users.noreply.github.com> Date: Fri, 18 Oct 2024 14:29:25 -0500 Subject: [PATCH] feat(local-ic): stream interaction and container logs (#1269) Co-authored-by: Galen Frechette --- Dockerfile.local-interchain | 14 +- local-interchain/docs/WINDOWS.md | 2 +- local-interchain/go.mod | 2 +- .../handlers/container_log_stream.go | 152 ++++++++++++++++ .../interchain/handlers/log_stream.go | 164 ++++++++++++++++++ local-interchain/interchain/handlers/types.go | 14 ++ local-interchain/interchain/logs.go | 25 ++- local-interchain/interchain/router/router.go | 39 +++-- local-interchain/interchain/start.go | 56 ++++-- 9 files changed, 427 insertions(+), 41 deletions(-) create mode 100644 local-interchain/interchain/handlers/container_log_stream.go create mode 100644 local-interchain/interchain/handlers/log_stream.go diff --git a/Dockerfile.local-interchain b/Dockerfile.local-interchain index 9ff14ae95..57fe4b6d6 100644 --- a/Dockerfile.local-interchain +++ b/Dockerfile.local-interchain @@ -3,7 +3,7 @@ # docker build . -t local-interchain:local -f Dockerfile.local-interchain # docker run -it local-interchain:local -FROM golang:1.22.2 as builder +FROM golang:1.22.5 AS builder # Set destination for COPY WORKDIR /app @@ -21,8 +21,16 @@ RUN cd local-interchain && make build RUN mv ./bin/local-ic /go/bin -# Reduces the size of the final image from 7GB -> 0.1GB -FROM busybox:1.35.0 as final +# Final stage +FROM debian:bookworm-slim AS final + +# Install certificates and required libraries +RUN apt-get update && \ + apt-get install -y ca-certificates libc6 && \ + update-ca-certificates && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + RUN mkdir -p /usr/local/bin COPY --from=builder /go/bin/local-ic /usr/local/bin/local-ic diff --git a/local-interchain/docs/WINDOWS.md b/local-interchain/docs/WINDOWS.md index f981815db..8dbca0a63 100644 --- a/local-interchain/docs/WINDOWS.md +++ b/local-interchain/docs/WINDOWS.md @@ -60,7 +60,7 @@ After installation, open a new cmd or shell, and you will be able to run `go ver ### 4. Downloading Make Make is a tool which controls the generation of executables and other non-source files of a program from the source files. It is necessary for building *`makefiles`*. -Make does not come with Windows, so we need to download the make binary which you can find provided by GNU [here](https://gnuwin32.sourceforge.net/packages/make.htm) and download the Binaries zip, or go to [this link](https://gnuwin32.sourceforge.net/downlinks/make-bin-zip.php) directly and begin downloading. +Make does not come with Windows, so we need to download the make binary which you can find provided by GNU [here](https://www.gnu.org/software/make/) and download the Binaries zip, or go to [this link](https://sourceforge.net/projects/gnuwin32/files/make/3.81/make-3.81-bin.zip/download?use_mirror=kent&download=) directly and begin downloading. 1. Extract the downloaded zip file 2. Go to the *`bin`* folder, copy *`make.exe`* diff --git a/local-interchain/go.mod b/local-interchain/go.mod index 4bb6ce867..8ef29ce5d 100644 --- a/local-interchain/go.mod +++ b/local-interchain/go.mod @@ -20,6 +20,7 @@ require ( github.com/cosmos/cosmos-sdk v0.50.9 github.com/cosmos/go-bip39 v1.0.0 github.com/go-playground/validator v9.31.0+incompatible + github.com/google/uuid v1.6.0 github.com/gorilla/handlers v1.5.2 github.com/gorilla/mux v1.8.1 github.com/spf13/cobra v1.8.1 @@ -139,7 +140,6 @@ require ( github.com/google/go-cmp v0.6.0 // indirect github.com/google/orderedcode v0.0.1 // indirect github.com/google/s2a-go v0.1.7 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/gax-go/v2 v2.12.3 // indirect github.com/gorilla/websocket v1.5.0 // indirect diff --git a/local-interchain/interchain/handlers/container_log_stream.go b/local-interchain/interchain/handlers/container_log_stream.go new file mode 100644 index 000000000..0f629ab44 --- /dev/null +++ b/local-interchain/interchain/handlers/container_log_stream.go @@ -0,0 +1,152 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + "regexp" + "strconv" + "strings" + "unicode" + + dockertypes "github.com/docker/docker/api/types" + dockerclient "github.com/docker/docker/client" + "github.com/strangelove-ventures/interchaintest/v8/chain/cosmos" + "go.uber.org/zap" +) + +var removeColorRegex = regexp.MustCompile("\x1b\\[[0-9;]*m") + +type ContainerStream struct { + ctx context.Context + logger *zap.Logger + cli *dockerclient.Client + authKey string + testName string + + nameToID map[string]string +} + +func NewContainerSteam(ctx context.Context, logger *zap.Logger, cli *dockerclient.Client, authKey, testName string, vals map[string][]*cosmos.ChainNode) *ContainerStream { + nameToID := make(map[string]string) + for _, nodes := range vals { + for _, node := range nodes { + nameToID[node.Name()] = node.ContainerID() + } + } + + return &ContainerStream{ + ctx: ctx, + authKey: authKey, + cli: cli, + logger: logger, + testName: testName, + nameToID: nameToID, + } +} + +func (cs *ContainerStream) StreamContainer(w http.ResponseWriter, r *http.Request) { + if err := VerifyAuthKey(cs.authKey, r); err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + containerID := r.URL.Query().Get("id") + if containerID == "" { + output := "No container ID provided. Available containers:\n" + for name, id := range cs.nameToID { + output += fmt.Sprintf("- %s: %s\n", name, id) + } + + fmt.Fprint(w, output) + fmt.Fprint(w, "Provide a container ID with ?id=") + return + } + + // if container id is in the cs.nameToID map, use the mapped container ID + if id, ok := cs.nameToID[containerID]; ok { + containerID = id + } else { + fmt.Fprintf(w, "Container ID %s not found\n", containerID) + return + } + + // http://127.0.0.1:8080/container_logs?id=&colored=true + isColored := strings.HasPrefix(strings.ToLower(r.URL.Query().Get("colored")), "t") + tailLines := tailLinesParam(r.URL.Query().Get("lines")) + + rr, err := cs.cli.ContainerLogs(cs.ctx, containerID, dockertypes.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: true, + Details: true, + Tail: strconv.FormatUint(tailLines, 10), + }) + if err != nil { + http.Error(w, "Unable to get container logs", http.StatusInternalServerError) + return + } + defer rr.Close() + + // Set headers to keep the connection open for SSE (Server-Sent Events) + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + // Flush ensures data is sent to the client immediately + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming unsupported", http.StatusInternalServerError) + return + } + + for { + buf := make([]byte, 8*1024) + n, err := rr.Read(buf) + if err != nil { + break + } + + text := string(buf[:n]) + if !isColored { + text, err = removeAnsiColorCodesFromText(string(buf[:n])) + if err != nil { + http.Error(w, "Unable to remove ANSI color codes", http.StatusInternalServerError) + return + } + } + + fmt.Fprint(w, cleanSpecialChars(text)) + flusher.Flush() + } +} + +func tailLinesParam(tailInput string) uint64 { + if tailInput == "" { + return defaultTailLines + } + + tailLines, err := strconv.ParseUint(tailInput, 10, 64) + if err != nil { + return defaultTailLines + } + + return tailLines +} + +func removeAnsiColorCodesFromText(text string) (string, error) { + return removeColorRegex.ReplaceAllString(text, ""), nil +} + +func cleanSpecialChars(text string) string { + return strings.Map(func(r rune) rune { + if r == '\n' { + return r + } + + if unicode.IsPrint(r) { + return r + } + return -1 + }, text) +} diff --git a/local-interchain/interchain/handlers/log_stream.go b/local-interchain/interchain/handlers/log_stream.go new file mode 100644 index 000000000..9279a31bb --- /dev/null +++ b/local-interchain/interchain/handlers/log_stream.go @@ -0,0 +1,164 @@ +package handlers + +import ( + "bufio" + "bytes" + "fmt" + "io" + "log" + "net/http" + "os" + "strconv" + "time" + + "go.uber.org/zap" +) + +const defaultTailLines = 50 + +type LogStream struct { + fName string + authKey string + logger *zap.Logger +} + +func NewLogSteam(logger *zap.Logger, file string, authKey string) *LogStream { + return &LogStream{ + fName: file, + authKey: authKey, + logger: logger, + } +} + +func (ls *LogStream) StreamLogs(w http.ResponseWriter, r *http.Request) { + if err := VerifyAuthKey(ls.authKey, r); err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + // Set headers to keep the connection open for SSE (Server-Sent Events) + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + // Flush ensures data is sent to the client immediately + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming unsupported", http.StatusInternalServerError) + return + } + + // Open the log file + file, err := os.Open(ls.fName) + if err != nil { + http.Error(w, "Unable to open log file", http.StatusInternalServerError) + return + } + defer file.Close() + + // Seek to the end of the file to read only new log entries + file.Seek(0, io.SeekEnd) + + // Read new lines from the log file + reader := bufio.NewReader(file) + + for { + select { + // In case client closes the connection, break out of loop + case <-r.Context().Done(): + return + default: + // Try to read a line + line, err := reader.ReadString('\n') + if err == nil { + // Send the log line to the client + fmt.Fprintf(w, "data: %s\n\n", line) + flusher.Flush() // Send to client immediately + } else { + // If no new log is available, wait for a short period before retrying + time.Sleep(100 * time.Millisecond) + } + } + } +} + +func (ls *LogStream) TailLogs(w http.ResponseWriter, r *http.Request) { + if err := VerifyAuthKey(ls.authKey, r); err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + var linesToTail uint64 = defaultTailLines + tailInput := r.URL.Query().Get("lines") + if tailInput != "" { + tailLines, err := strconv.ParseUint(tailInput, 10, 64) + if err != nil { + http.Error(w, "Invalid lines input", http.StatusBadRequest) + return + } + linesToTail = tailLines + } + + logs := TailFile(ls.logger, ls.fName, linesToTail) + for _, log := range logs { + fmt.Fprintf(w, "%s\n", log) + } +} + +func TailFile(logger *zap.Logger, logFile string, lines uint64) []string { + // read the last n lines of a file + file, err := os.Open(logFile) + if err != nil { + log.Fatal(err) + } + defer file.Close() + + totalLines, err := lineCounter(file) + if err != nil { + log.Fatal(err) + } + + if lines > uint64(totalLines) { + lines = uint64(totalLines) + } + + file.Seek(0, io.SeekStart) + reader := bufio.NewReader(file) + + var logs []string + for i := 0; uint64(i) < totalLines-lines; i++ { + _, _, err := reader.ReadLine() + if err != nil { + logger.Fatal("Error reading log file", zap.Error(err)) + } + } + + for { + line, _, err := reader.ReadLine() + if err == io.EOF { + break + } + logs = append(logs, string(line)) + } + + return logs +} + +func lineCounter(r io.Reader) (uint64, error) { + buf := make([]byte, 32*1024) + var count uint64 = 0 + lineSep := []byte{'\n'} + + for { + c, err := r.Read(buf) + count += uint64(bytes.Count(buf[:c], lineSep)) + + switch { + case err == io.EOF: + return count, nil + + case err != nil: + return count, err + } + } +} diff --git a/local-interchain/interchain/handlers/types.go b/local-interchain/interchain/handlers/types.go index 8889b241a..19de04af2 100644 --- a/local-interchain/interchain/handlers/types.go +++ b/local-interchain/interchain/handlers/types.go @@ -2,10 +2,24 @@ package handlers import ( "encoding/json" + "fmt" + "net/http" "github.com/strangelove-ventures/interchaintest/v8/ibc" ) +func VerifyAuthKey(expected string, r *http.Request) error { + if expected == "" { + return nil + } + + if r.URL.Query().Get("auth_key") == expected { + return nil + } + + return fmt.Errorf("unauthorized, incorrect or no ?auth_key= provided") +} + type IbcChainConfigAlias struct { Type string `json:"type" yaml:"type"` Name string `json:"name" yaml:"name"` diff --git a/local-interchain/interchain/logs.go b/local-interchain/interchain/logs.go index 9ec2ba3b3..e750d9ee5 100644 --- a/local-interchain/interchain/logs.go +++ b/local-interchain/interchain/logs.go @@ -73,21 +73,20 @@ func DumpChainsInfoToLogs(configDir string, config *types.Config, chains []ibc.C } // == Zap Logger == -func getLoggerConfig() zap.Config { - config := zap.NewDevelopmentConfig() +func InitLogger(logFile *os.File) (*zap.Logger, error) { + // Production logger that saves logs to file and console. + pe := zap.NewProductionEncoderConfig() + pe.EncodeTime = zapcore.TimeEncoderOfLayout(time.TimeOnly) - config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder - config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder + fileEncoder := zapcore.NewJSONEncoder(pe) + consoleEncoder := zapcore.NewConsoleEncoder(pe) - return config -} + level := zap.InfoLevel -func InitLogger() (*zap.Logger, error) { - config := getLoggerConfig() - logger, err := config.Build() - if err != nil { - return nil, err - } + core := zapcore.NewTee( + zapcore.NewCore(fileEncoder, zapcore.AddSync(logFile), level), + zapcore.NewCore(consoleEncoder, zapcore.AddSync(os.Stdout), level), + ) - return logger, nil + return zap.New(core), nil } diff --git a/local-interchain/interchain/router/router.go b/local-interchain/interchain/router/router.go index 3694ed634..7501a22f0 100644 --- a/local-interchain/interchain/router/router.go +++ b/local-interchain/interchain/router/router.go @@ -8,12 +8,14 @@ import ( "os" "path/filepath" + "github.com/docker/docker/client" "github.com/gorilla/mux" ictypes "github.com/strangelove-ventures/interchaintest/local-interchain/interchain/types" "github.com/strangelove-ventures/interchaintest/local-interchain/interchain/util" "github.com/strangelove-ventures/interchaintest/v8" "github.com/strangelove-ventures/interchaintest/v8/chain/cosmos" "github.com/strangelove-ventures/interchaintest/v8/ibc" + "go.uber.org/zap" "github.com/strangelove-ventures/interchaintest/local-interchain/interchain/handlers" ) @@ -23,22 +25,39 @@ type Route struct { Methods []string `json:"methods" yaml:"methods"` } +type RouterConfig struct { + ibc.RelayerExecReporter + + Config *ictypes.Config + CosmosChains map[string]*cosmos.CosmosChain + Vals map[string][]*cosmos.ChainNode + Relayer ibc.Relayer + AuthKey string + InstallDir string + LogFile string + TestName string + Logger *zap.Logger + DockerClient *client.Client +} + func NewRouter( ctx context.Context, ic *interchaintest.Interchain, - config *ictypes.Config, - cosmosChains map[string]*cosmos.CosmosChain, - vals map[string][]*cosmos.ChainNode, - relayer ibc.Relayer, - authKey string, - eRep ibc.RelayerExecReporter, - installDir string, + rc *RouterConfig, ) *mux.Router { r := mux.NewRouter() - infoH := handlers.NewInfo(config, installDir, ctx, ic, cosmosChains, vals, relayer, eRep) + infoH := handlers.NewInfo(rc.Config, rc.InstallDir, ctx, ic, rc.CosmosChains, rc.Vals, rc.Relayer, rc.RelayerExecReporter) r.HandleFunc("/info", infoH.GetInfo).Methods(http.MethodGet) + // interaction logs + logStream := handlers.NewLogSteam(rc.Logger, rc.LogFile, rc.AuthKey) + r.HandleFunc("/logs", logStream.StreamLogs).Methods(http.MethodGet) + r.HandleFunc("/logs_tail", logStream.TailLogs).Methods(http.MethodGet) // ?lines= + + containerStream := handlers.NewContainerSteam(ctx, rc.Logger, rc.DockerClient, rc.AuthKey, rc.TestName, rc.Vals) + r.HandleFunc("/container_logs", containerStream.StreamContainer).Methods(http.MethodGet) // ?container=&colored=true&lines=10000 + wd, err := os.Getwd() if err != nil { panic(err) @@ -60,10 +79,10 @@ func NewRouter( log.Printf("chain_registry_assets.json not found in %s, not exposing endpoint.", wd) } - actionsH := handlers.NewActions(ctx, ic, cosmosChains, vals, relayer, eRep, authKey) + actionsH := handlers.NewActions(ctx, ic, rc.CosmosChains, rc.Vals, rc.Relayer, rc.RelayerExecReporter, rc.AuthKey) r.HandleFunc("/", actionsH.PostActions).Methods(http.MethodPost) - uploaderH := handlers.NewUploader(ctx, vals, authKey) + uploaderH := handlers.NewUploader(ctx, rc.Vals, rc.AuthKey) r.HandleFunc("/upload", uploaderH.PostUpload).Methods(http.MethodPost) availableRoutes := getAllMethods(*r) diff --git a/local-interchain/interchain/start.go b/local-interchain/interchain/start.go index 68d321ef6..e40b67a10 100644 --- a/local-interchain/interchain/start.go +++ b/local-interchain/interchain/start.go @@ -3,7 +3,6 @@ package interchain import ( "context" "fmt" - "log" "math" "net/http" "os" @@ -12,7 +11,9 @@ import ( "strings" "sync" "syscall" + "time" + "github.com/google/uuid" "github.com/gorilla/handlers" "github.com/strangelove-ventures/interchaintest/local-interchain/interchain/router" "github.com/strangelove-ventures/interchaintest/local-interchain/interchain/types" @@ -53,11 +54,27 @@ func StartChain(installDir, chainCfgFile string, ac *types.AppStartConfig) { } }() + // very unique file to ensure if multiple start at the same time. + logFile, err := interchaintest.CreateLogFile(fmt.Sprintf("%d-%s.json", time.Now().Unix(), uuid.New())) + if err != nil { + panic(err) + } + defer func() { + if err := logFile.Close(); err != nil { + fmt.Println("Error closing log file: ", err) + } + + if err := os.Remove(logFile.Name()); err != nil { + fmt.Println("Error deleting log file: ", err) + } + }() + // Logger for ICTest functions only. - logger, err := InitLogger() + logger, err := InitLogger(logFile) if err != nil { panic(err) } + logger.Debug("Log file created", zap.String("file", logFile.Name())) config := ac.Cfg @@ -88,7 +105,7 @@ func StartChain(installDir, chainCfgFile string, ac *types.AppStartConfig) { } if err := VerifyIBCPaths(ibcpaths); err != nil { - log.Fatal("VerifyIBCPaths", err) + logger.Fatal("VerifyIBCPaths", zap.Error(err)) } // Create chain factory for all the chains @@ -98,7 +115,7 @@ func StartChain(installDir, chainCfgFile string, ac *types.AppStartConfig) { chains, err := cf.Chains(testName) if err != nil { - log.Fatal("cf.Chains", err) + logger.Fatal("ChainFactory chains", zap.Error(err)) } for _, chain := range chains { @@ -111,7 +128,8 @@ func StartChain(installDir, chainCfgFile string, ac *types.AppStartConfig) { } // Base setup - rep := testreporter.NewNopReporter() + + rep := testreporter.NewReporter(logFile) eRep = rep.RelayerExecReporter(&fakeT) client, network := interchaintest.DockerSetup(fakeT) @@ -181,7 +199,7 @@ func StartChain(installDir, chainCfgFile string, ac *types.AppStartConfig) { SkipPathCreation: false, }) if err != nil { - log.Fatalf("ic.Build: %v", err) + logger.Fatal("Interchain Build", zap.Error(err)) } if relayer != nil && len(ibcpaths) > 0 { @@ -191,12 +209,12 @@ func StartChain(installDir, chainCfgFile string, ac *types.AppStartConfig) { } if err := relayer.StartRelayer(ctx, eRep, paths...); err != nil { - log.Fatal("relayer.StartRelayer", err) + logger.Fatal("Relayer StartRelayer", zap.Error(err)) } defer func() { if err := relayer.StopRelayer(ctx, eRep); err != nil { - log.Fatal("relayer.StopRelayer", err) + logger.Error("Relayer StopRelayer", zap.Error(err)) } }() } @@ -215,7 +233,7 @@ func StartChain(installDir, chainCfgFile string, ac *types.AppStartConfig) { for ibcPath, chain := range icsProviderPaths { if provider, ok := chain.(*cosmos.CosmosChain); ok { if err := provider.FinishICSProviderSetup(ctx, relayer, eRep, ibcPath); err != nil { - log.Fatal("FinishICSProviderSetup", err) + logger.Error("FinishICSProviderSetup", zap.Error(err)) } } } @@ -230,7 +248,19 @@ func StartChain(installDir, chainCfgFile string, ac *types.AppStartConfig) { } } - r := router.NewRouter(ctx, ic, config, cosmosChains, vals, relayer, ac.AuthKey, eRep, installDir) + r := router.NewRouter(ctx, ic, &router.RouterConfig{ + Logger: logger, + RelayerExecReporter: eRep, + Config: config, + CosmosChains: cosmosChains, + DockerClient: client, + Vals: vals, + Relayer: relayer, + AuthKey: ac.AuthKey, + InstallDir: installDir, + LogFile: logFile.Name(), + TestName: testName, + }) config.Server = types.RestServer{ Host: ac.Address, @@ -249,14 +279,14 @@ func StartChain(installDir, chainCfgFile string, ac *types.AppStartConfig) { // Where ORIGIN_ALLOWED is like `scheme://dns[:port]`, or `*` (insecure) corsHandler := handlers.CORS( handlers.AllowedOrigins([]string{"*"}), - handlers.AllowedHeaders([]string{"*"}), + handlers.AllowedHeaders([]string{"Content-Type", "Authorization", "Accept"}), handlers.AllowedMethods([]string{"GET", "HEAD", "POST", "PUT", "OPTIONS", "DELETE"}), handlers.AllowCredentials(), handlers.ExposedHeaders([]string{"*"}), ) if err := http.ListenAndServe(serverAddr, corsHandler(r)); err != nil { - log.Default().Println(err) + logger.Error("HTTP ListenAndServe", zap.Error(err)) } }() @@ -270,7 +300,7 @@ func StartChain(installDir, chainCfgFile string, ac *types.AppStartConfig) { // Save to logs.json file for runtime chain information. DumpChainsInfoToLogs(installDir, config, chains, connections) - log.Println("\nLocal-IC API is running on ", fmt.Sprintf("http://%s:%s", config.Server.Host, config.Server.Port)) + logger.Info("Local-IC API is running", zap.String("url", fmt.Sprintf("http://%s:%s", config.Server.Host, config.Server.Port))) if err = testutil.WaitForBlocks(ctx, math.MaxInt, chains[0]); err != nil { // when the network is stopped / killed (ctrl + c), ignore error