Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(local-ic): stream interaction and container logs #1269

Merged
merged 21 commits into from
Oct 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions Dockerfile.local-interchain
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/*

jtieri marked this conversation as resolved.
Show resolved Hide resolved
RUN mkdir -p /usr/local/bin
COPY --from=builder /go/bin/local-ic /usr/local/bin/local-ic

Expand Down
2 changes: 1 addition & 1 deletion local-interchain/docs/WINDOWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`*
Expand Down
2 changes: 1 addition & 1 deletion local-interchain/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
152 changes: 152 additions & 0 deletions local-interchain/interchain/handlers/container_log_stream.go
Original file line number Diff line number Diff line change
@@ -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")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this necessary because certain operating systems or terminals cannot render the colors in the logs?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is being forwarded over a REST api, it is always the case. So most users parsing that data don't care about. We may in the future want to parse out the colors which is why it is possible to opt out and see those ASNI escape sequences


type ContainerStream struct {
ctx context.Context
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a specific reason we have to use a Context as a field member of this struct vs. piping a Context into the StreamContainer method or wherever a Context is needed for the ContainerStream?

It's typically more idiomatic to pass a Context around vs. storing one inside of a struct type

One of the exceptions to this rule are typically when you have an external request that starts a background process/operation.

Edit: After reading the rest of the code it seems like that is what may be happening here when a user makes a request to /container_logs so this may be a correct usage of context in a struct but figured I would ask for clarity.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tldr; better Dev UX for the same relative solution

It's cleaner than having to write methods return func(w http.ResponseWriter, req *http.Request) signatures just for ctx to be passed in in a wrapped type.

Current:

r.HandleFunc("/container_logs", containerStream.StreamContainer)

Would be required change

r.HandleFunc("/container_logs", containerStream.handleStreamContainer(ctx))

func (cs *ContainerStream) handleStreamContainer(ctx context) func(w http.ResponseWriter, req *http.Request) {

   return func(w http.ResponseWriter, req *http.Request) {
      // all the same logic here, just wrapped now
    }
}

ctx is only used for timeout limits and set on startup. So any runtime changes are unaffected in either approach - and the first approach is just net easier for a new contributor to wrap their head around.

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=<containerID>")
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=<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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What was the reasoning for instantiating the slice with this specific value? Does changing this value break anything? If so a comment regarding the reason for this specific size could be helpful.

Copy link
Member Author

@Reecepbcups Reecepbcups Oct 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1024 is the standard. Due to the size of some JSON blobs sent through (relayer), it was exceeding that limit for Galen. Causing parsing issues

It was exceeding the size for some relayer JSON blobs for Galen. He was unable to parse it since it came in multiple chunks. I wanted to ensure we have enough of a buffer and 8192 felt like a good spot (21024 was still too small, I am sure 4 would have been fine but increased just to be sure since the data sent it relative small and controlled anyways)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not necessary but this is the type of decision that can be helpful to document via a comment. You never know when someone is going to come across and naively change values without understanding what the implications are.

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)
}
164 changes: 164 additions & 0 deletions local-interchain/interchain/handlers/log_stream.go
Original file line number Diff line number Diff line change
@@ -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
}
}
}
14 changes: 14 additions & 0 deletions local-interchain/interchain/handlers/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Loading
Loading