From 321b385d312e7677598373a6afc2e6766917b70d Mon Sep 17 00:00:00 2001 From: Tu Nguyen Date: Tue, 7 Jan 2025 13:46:23 -0800 Subject: [PATCH 1/9] containerized terramino, added redis to docker compose, hvs optional --- Dockerfile.backend | 14 ++++++++++++ Dockerfile.frontend | 6 +++++ docker-compose.yml | 34 +++++++++++++++++++++++++++ main.go | 56 +++++++++++++++++++++++++++++++++------------ nginx.conf | 16 +++++++++++++ 5 files changed, 112 insertions(+), 14 deletions(-) create mode 100644 Dockerfile.backend create mode 100644 Dockerfile.frontend create mode 100644 docker-compose.yml create mode 100644 nginx.conf diff --git a/Dockerfile.backend b/Dockerfile.backend new file mode 100644 index 0000000..3b2b8a5 --- /dev/null +++ b/Dockerfile.backend @@ -0,0 +1,14 @@ +FROM golang:1.22-alpine + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN go build -o terramino + +EXPOSE 8080 + +CMD ["./terramino"] \ No newline at end of file diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..1e62419 --- /dev/null +++ b/Dockerfile.frontend @@ -0,0 +1,6 @@ +FROM nginx:alpine + +COPY web /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..521ce68 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +version: '3.8' + +services: + frontend: + build: + context: . + dockerfile: Dockerfile.frontend + ports: + - "80:80" + depends_on: + - backend + + backend: + build: + context: . + dockerfile: Dockerfile.backend + environment: + - REDIS_HOST=redis + - REDIS_PORT=6379 + - TERRAMINO_PORT=8080 + ports: + - "8080:8080" + depends_on: + - redis + + redis: + image: redis:alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + +volumes: + redis_data: \ No newline at end of file diff --git a/main.go b/main.go index 712e342..3e32e9c 100644 --- a/main.go +++ b/main.go @@ -17,18 +17,37 @@ import ( ) type TerraminoData struct { - HVSClient *terraminogo.HVSClient - redisClient *redis.Client - ctx context.Context - appName string + HVSClient *terraminogo.HVSClient + redisClient *redis.Client + ctx context.Context + appName string + useDirectRedis bool + redisHost string + redisPort string + redisPassword string } func main() { t := &TerraminoData{} - t.HVSClient = terraminogo.NewHVSClient() - t.redisClient = nil t.ctx = context.Background() + // Check if direct Redis connection is configured + redisHost, hasRedisHost := os.LookupEnv("REDIS_HOST") + redisPort, hasRedisPort := os.LookupEnv("REDIS_PORT") + + if hasRedisHost && hasRedisPort { + t.useDirectRedis = true + t.redisHost = redisHost + t.redisPort = redisPort + t.redisPassword = os.Getenv("REDIS_PASSWORD") + t.HVSClient = nil + } else { + t.useDirectRedis = false + t.HVSClient = terraminogo.NewHVSClient() + } + + t.redisClient = nil + appName, envExists := os.LookupEnv("APP_NAME") if !envExists { appName = "terramino" @@ -119,16 +138,25 @@ func (t *TerraminoData) getRedisClient() *redis.Client { // Either we don't have a connection, or it's no longer valid // Create a new client + var redisIP, redisPort, redisPassword string + var err error - // Check for connection info in HVS - redisIP, err := t.HVSClient.GetSecret(t.appName, "redis_ip") - if err != nil { - // No Redis server is available - t.redisClient = nil - return nil + if t.useDirectRedis { + redisIP = t.redisHost + redisPort = t.redisPort + redisPassword = t.redisPassword + } else { + // Check for connection info in HVS + redisIP, err = t.HVSClient.GetSecret(t.appName, "redis_ip") + if err != nil { + // No Redis server is available + t.redisClient = nil + return nil + } + redisPort, _ = t.HVSClient.GetSecret(t.appName, "redis_port") + redisPassword, _ = t.HVSClient.GetSecret(t.appName, "redis_password") } - redisPort, _ := t.HVSClient.GetSecret(t.appName, "redis_port") - redisPassword, _ := t.HVSClient.GetSecret(t.appName, "redis_password") + t.redisClient = redis.NewClient(&redis.Options{ Addr: fmt.Sprintf("%s:%s", redisIP, redisPort), Password: redisPassword, diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..8139ae9 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,16 @@ +server { + listen 80; + server_name localhost; + + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri $uri/ /index.html; + } + + location /score { + proxy_pass http://backend:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} \ No newline at end of file From 35e5fabc40746842df757b527d1e7e246a3fe860 Mon Sep 17 00:00:00 2001 From: Tu Nguyen Date: Tue, 7 Jan 2025 15:50:33 -0800 Subject: [PATCH 2/9] add cli client, readme --- Dockerfile.backend | 8 +- README.md | 44 ++++++ cmd/cli/main.go | 176 ++++++++++++++++++++++++ docker-compose.yml | 2 - go.mod | 1 + go.sum | 4 +- internal/game/board.go | 144 +++++++++++++++++++ internal/game/game.go | 174 +++++++++++++++++++++++ internal/highscore/highscore.go | 163 ++++++++++++++++++++++ internal/{ => hvs_client}/hvs_client.go | 2 +- main.go | 167 +++++----------------- 11 files changed, 743 insertions(+), 142 deletions(-) create mode 100644 README.md create mode 100644 cmd/cli/main.go create mode 100644 internal/game/board.go create mode 100644 internal/game/game.go create mode 100644 internal/highscore/highscore.go rename internal/{ => hvs_client}/hvs_client.go (98%) diff --git a/Dockerfile.backend b/Dockerfile.backend index 3b2b8a5..5ae6fc4 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -1,4 +1,4 @@ -FROM golang:1.22-alpine +FROM golang:1.22 WORKDIR /app @@ -7,8 +7,12 @@ RUN go mod download COPY . . +# Build both the server and CLI client RUN go build -o terramino +RUN go build -o terramino-cli cmd/cli/main.go EXPOSE 8080 -CMD ["./terramino"] \ No newline at end of file +# Use environment variable to determine which binary to run +ENV RUN_MODE=server +CMD if [ "$RUN_MODE" = "cli" ]; then ./terramino-cli; else ./terramino; fi \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c30bbbd --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# Terramino Go + +A HashiCorp-themed Tetris-like game with web and CLI interfaces, built in Go. + +## Quick Start + +```bash +# Start all services +docker compose up + +# Play in browser +open http://localhost + +# Play in terminal +docker compose exec -it backend ./terramino-cli +``` + +## Development + +Prerequisites: Docker Compose, Go 1.22+ + +Environment variables (`.env`): + +- `REDIS_HOST`: Redis hostname (default: redis) +- `REDIS_PORT`: Redis port (default: 6379) +- `TERRAMINO_PORT`: Backend port (default: 8080) + +Local development: + +```bash +# Run backend +go run main.go + +# Run CLI +go run cmd/cli/main.go +``` + +## Project Structure +``` +├── cmd/cli/ # CLI client +├── internal/ # Game logic & high scores +├── web/ # Frontend assets +└── *.go # Backend server +``` \ No newline at end of file diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 0000000..632dfff --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,176 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/hashicorp-education/terraminogo/internal/game" +) + +const ( + clearScreen = "\033[H\033[2J" + moveCursor = "\033[%d;%dH" + escapeKey = 27 + spaceKey = 32 +) + +func main() { + reader := bufio.NewReader(os.Stdin) + + // Use alternate screen buffer and hide cursor + fmt.Print("\033[?1049h") + fmt.Print("\033[?25l") + defer fmt.Print("\033[?1049l\033[?25h") + + clearTerminal() + fmt.Print("Terramino CLI\r\n") + fmt.Print("Controls:\r\n") + fmt.Print("← → : Move left/right\r\n") + fmt.Print("↑ : Rotate\r\n") + fmt.Print("↓ : Soft drop\r\n") + fmt.Print("Space : Hard drop\r\n") + fmt.Print("q : Quit\r\n") + fmt.Print("\r\nPress Enter to start...\r\n") + + reader.ReadString('\n') + + // Now set up raw mode after user presses Enter + rawMode := exec.Command("stty", "raw", "-echo") + rawMode.Stdin = os.Stdin + _ = rawMode.Run() + + // Restore on exit + defer func() { + cookedMode := exec.Command("stty", "-raw", "echo") + cookedMode.Stdin = os.Stdin + _ = cookedMode.Run() + }() + + startGame(reader) +} + +func startGame(reader *bufio.Reader) { + g := game.NewGame() + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + // Start input reading in a separate goroutine + inputChan := make(chan string) + go func() { + for { + char, err := reader.ReadByte() + if err != nil { + continue + } + + if char == escapeKey { + // Read the next two bytes for arrow keys + secondChar, err := reader.ReadByte() + if err != nil { + continue + } + if secondChar == '[' { + thirdChar, err := reader.ReadByte() + if err != nil { + continue + } + switch thirdChar { + case 'A': // Up arrow + inputChan <- "up" + case 'B': // Down arrow + inputChan <- "down" + case 'C': // Right arrow + inputChan <- "right" + case 'D': // Left arrow + inputChan <- "left" + } + } + continue + } + + if char == spaceKey { + inputChan <- "space" + } else if char == 'q' { + inputChan <- "quit" + } + } + }() + + for { + select { + case <-ticker.C: + g.MovePiece(0, 1) // Move down automatically + renderGame(g) + case input := <-inputChan: + switch input { + case "quit": + return + case "left": + g.MovePiece(-1, 0) + case "right": + g.MovePiece(1, 0) + case "up": + g.RotatePiece() + case "down": + g.MovePiece(0, 1) + case "space": + // Hard drop - move down until collision + for g.MovePiece(0, 1) { + // Keep moving down + } + } + renderGame(g) + } + + if g.Board.GameOver { + fmt.Printf("\nGame Over! Score: %d\n", g.Board.Score) + fmt.Print("Press 'q' to quit...\n") + for { + char, _ := reader.ReadByte() + if char == 'q' { + return + } + } + } + } +} + +func renderGame(g *game.Game) { + clearTerminal() + state := g.Board.GetState() + + // Print title + fmt.Print("Terramino\r\n") + + // Print top border + fmt.Print("┌" + strings.Repeat("──", game.BoardWidth) + "┐\r\n") + + // Print board + for _, row := range state.Grid { + fmt.Print("│") + for _, cell := range row { + fmt.Print(cell + cell) // Print each cell twice for better aspect ratio + } + fmt.Print("│\r\n") + } + + // Print bottom border + fmt.Print("└" + strings.Repeat("──", game.BoardWidth) + "┘\r\n") + fmt.Printf("Score: %d\r\n", state.Score) + fmt.Print("Press 'q' to quit...\r\n") +} + +func clearTerminal() { + if runtime.GOOS == "windows" { + cmd := exec.Command("cmd", "/c", "cls") + cmd.Stdout = os.Stdout + cmd.Run() + } else { + fmt.Print(clearScreen) + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 521ce68..18a35b7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: frontend: build: diff --git a/go.mod b/go.mod index 9cae42f..83a517c 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,7 @@ require ( go.opentelemetry.io/otel/trace v1.17.0 // indirect golang.org/x/net v0.19.0 // indirect golang.org/x/oauth2 v0.15.0 // indirect + golang.org/x/sys v0.17.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.31.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index a1fa048..3667233 100644 --- a/go.sum +++ b/go.sum @@ -119,8 +119,8 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/game/board.go b/internal/game/board.go new file mode 100644 index 0000000..d76eee4 --- /dev/null +++ b/internal/game/board.go @@ -0,0 +1,144 @@ +package game + +import ( + "encoding/json" +) + +const ( + BoardWidth = 10 + BoardHeight = 20 +) + +// Piece represents a Tetris piece +type Piece struct { + Shape [][]bool + X, Y int + PieceType string +} + +// Board represents the game board +type Board struct { + Grid [][]string + CurrentPiece *Piece + Score int + GameOver bool +} + +// NewBoard creates a new game board +func NewBoard() *Board { + grid := make([][]string, BoardHeight) + for i := range grid { + grid[i] = make([]string, BoardWidth) + for j := range grid[i] { + grid[i][j] = "·" + } + } + return &Board{ + Grid: grid, + Score: 0, + } +} + +// State represents the game state for API responses +type State struct { + Grid [][]string `json:"grid"` + Score int `json:"score"` + GameOver bool `json:"gameOver"` +} + +// GetState returns the current game state +func (b *Board) GetState() State { + // Create a copy of the grid + gridCopy := make([][]string, len(b.Grid)) + for i := range b.Grid { + gridCopy[i] = make([]string, len(b.Grid[i])) + copy(gridCopy[i], b.Grid[i]) + } + + // Add current piece to the grid copy + if b.CurrentPiece != nil { + for y := range b.CurrentPiece.Shape { + for x := range b.CurrentPiece.Shape[y] { + if b.CurrentPiece.Shape[y][x] { + newY := b.CurrentPiece.Y + y + newX := b.CurrentPiece.X + x + if newY >= 0 && newY < BoardHeight && newX >= 0 && newX < BoardWidth { + gridCopy[newY][newX] = "█" + } + } + } + } + } + + return State{ + Grid: gridCopy, + Score: b.Score, + GameOver: b.GameOver, + } +} + +// ToJSON converts the game state to JSON +func (s State) ToJSON() ([]byte, error) { + return json.Marshal(s) +} + +// Pieces definitions +var Pieces = []Piece{ + { + // I piece + Shape: [][]bool{ + {true, true, true, true}, + }, + PieceType: "I", + }, + { + // O piece + Shape: [][]bool{ + {true, true}, + {true, true}, + }, + PieceType: "O", + }, + { + // T piece + Shape: [][]bool{ + {false, true, false}, + {true, true, true}, + }, + PieceType: "T", + }, + { + // L piece + Shape: [][]bool{ + {true, false}, + {true, false}, + {true, true}, + }, + PieceType: "L", + }, + { + // J piece + Shape: [][]bool{ + {false, true}, + {false, true}, + {true, true}, + }, + PieceType: "J", + }, + { + // S piece + Shape: [][]bool{ + {false, true, true}, + {true, true, false}, + }, + PieceType: "S", + }, + { + // Z piece + Shape: [][]bool{ + {true, true, false}, + {false, true, true}, + }, + PieceType: "Z", + }, +} diff --git a/internal/game/game.go b/internal/game/game.go new file mode 100644 index 0000000..61d945e --- /dev/null +++ b/internal/game/game.go @@ -0,0 +1,174 @@ +package game + +import ( + "math/rand" +) + +// Game represents a Tetris game instance +type Game struct { + Board *Board +} + +// NewGame creates a new game instance +func NewGame() *Game { + game := &Game{ + Board: NewBoard(), + } + game.SpawnPiece() + return game +} + +// SpawnPiece creates a new piece at the top of the board +func (g *Game) SpawnPiece() { + if g.Board.GameOver { + return + } + + // Choose a random piece + piece := Pieces[rand.Intn(len(Pieces))] + piece.X = BoardWidth/2 - len(piece.Shape[0])/2 + piece.Y = 0 + + // Check if the new piece can be placed + if g.checkCollision(&piece) { + g.Board.GameOver = true + return + } + + g.Board.CurrentPiece = &piece +} + +// MovePiece moves the current piece in the specified direction +func (g *Game) MovePiece(dx, dy int) bool { + if g.Board.CurrentPiece == nil || g.Board.GameOver { + return false + } + + piece := *g.Board.CurrentPiece + piece.X += dx + piece.Y += dy + + if g.checkCollision(&piece) { + if dy > 0 { + // Piece has landed + g.lockPiece() + g.clearLines() + g.SpawnPiece() + } + return false + } + + g.Board.CurrentPiece = &piece + return true +} + +// RotatePiece rotates the current piece clockwise +func (g *Game) RotatePiece() bool { + if g.Board.CurrentPiece == nil || g.Board.GameOver { + return false + } + + piece := *g.Board.CurrentPiece + // Create new rotated shape + oldShape := piece.Shape + newShape := make([][]bool, len(oldShape[0])) + for i := range newShape { + newShape[i] = make([]bool, len(oldShape)) + for j := range newShape[i] { + newShape[i][j] = oldShape[len(oldShape)-1-j][i] + } + } + piece.Shape = newShape + + if g.checkCollision(&piece) { + return false + } + + g.Board.CurrentPiece = &piece + return true +} + +// checkCollision checks if a piece collides with the board boundaries or other pieces +func (g *Game) checkCollision(piece *Piece) bool { + for y := range piece.Shape { + for x := range piece.Shape[y] { + if !piece.Shape[y][x] { + continue + } + + newY := piece.Y + y + newX := piece.X + x + + // Check boundaries + if newX < 0 || newX >= BoardWidth || newY >= BoardHeight { + return true + } + + // Check collision with locked pieces + if newY >= 0 && g.Board.Grid[newY][newX] == "█" { + return true + } + } + } + return false +} + +// lockPiece locks the current piece in place +func (g *Game) lockPiece() { + if g.Board.CurrentPiece == nil { + return + } + + piece := g.Board.CurrentPiece + for y := range piece.Shape { + for x := range piece.Shape[y] { + if piece.Shape[y][x] { + newY := piece.Y + y + newX := piece.X + x + if newY >= 0 && newY < BoardHeight && newX >= 0 && newX < BoardWidth { + g.Board.Grid[newY][newX] = "█" + } + } + } + } +} + +// clearLines removes completed lines and updates the score +func (g *Game) clearLines() { + linesCleared := 0 + for y := BoardHeight - 1; y >= 0; y-- { + if g.isLineFull(y) { + g.removeLine(y) + linesCleared++ + y++ // Check the same line again after shifting + } + } + + // Update score (more points for clearing multiple lines at once) + if linesCleared > 0 { + g.Board.Score += linesCleared * linesCleared * 100 + } +} + +// isLineFull checks if a line is complete +func (g *Game) isLineFull(y int) bool { + for x := 0; x < BoardWidth; x++ { + if g.Board.Grid[y][x] != "█" { + return false + } + } + return true +} + +// removeLine removes a line and shifts everything above down +func (g *Game) removeLine(y int) { + // Shift everything down + for i := y; i > 0; i-- { + copy(g.Board.Grid[i], g.Board.Grid[i-1]) + } + + // Clear top line + for x := 0; x < BoardWidth; x++ { + g.Board.Grid[0][x] = "·" + } +} diff --git a/internal/highscore/highscore.go b/internal/highscore/highscore.go new file mode 100644 index 0000000..8d40c94 --- /dev/null +++ b/internal/highscore/highscore.go @@ -0,0 +1,163 @@ +package highscore + +import ( + "context" + "fmt" + "io" + "log" + "net/http" + "strconv" + + "github.com/hashicorp-education/terraminogo/internal/hvs_client" + "github.com/redis/go-redis/v9" +) + +// Manager handles high score operations +type Manager struct { + HVSClient *hvs_client.HVSClient + redisClient *redis.Client + ctx context.Context + appName string + useDirectRedis bool + redisHost string + redisPort string + redisPassword string +} + +// NewManager creates a new high score manager +func NewManager(ctx context.Context, appName string) *Manager { + return &Manager{ + ctx: ctx, + appName: appName, + } +} + +// ConfigureRedis sets up Redis connection details +func (m *Manager) ConfigureRedis(host, port, password string) { + m.useDirectRedis = true + m.redisHost = host + m.redisPort = port + m.redisPassword = password + m.HVSClient = nil +} + +// ConfigureHVS sets up HVS client for Redis connection details +func (m *Manager) ConfigureHVS(hvsClient *hvs_client.HVSClient) { + m.useDirectRedis = false + m.HVSClient = hvsClient +} + +func (m *Manager) getRedisClient() *redis.Client { + if m.redisClient != nil { + // We have an existing connection, make sure it's still valid + pingResp := m.redisClient.Ping(m.ctx) + if pingResp.Err() == nil { + // Connection is valid, return client + return m.redisClient + } + } + + // Either we don't have a connection, or it's no longer valid + // Create a new client + var redisIP, redisPort, redisPassword string + var err error + + if m.useDirectRedis { + redisIP = m.redisHost + redisPort = m.redisPort + redisPassword = m.redisPassword + } else { + // Check for connection info in HVS + redisIP, err = m.HVSClient.GetSecret(m.appName, "redis_ip") + if err != nil { + // No Redis server is available + m.redisClient = nil + return nil + } + redisPort, _ = m.HVSClient.GetSecret(m.appName, "redis_port") + redisPassword, _ = m.HVSClient.GetSecret(m.appName, "redis_password") + } + + m.redisClient = redis.NewClient(&redis.Options{ + Addr: fmt.Sprintf("%s:%s", redisIP, redisPort), + Password: redisPassword, + DB: 0, + }) + + // Check connection + pingResp := m.redisClient.Ping(m.ctx) + if pingResp.Err() != nil { + // Error connecting to the server + log.Println(pingResp.Err()) + return nil + } + + return m.redisClient +} + +// GetScore retrieves the current high score +func (m *Manager) GetScore() int { + redisClient := m.getRedisClient() + if redisClient != nil { + val, err := redisClient.Get(m.ctx, "score").Result() + if err == nil { + iVal, _ := strconv.Atoi(val) + return iVal + } + } + return 0 +} + +// SetScore updates the high score if the new score is higher +func (m *Manager) SetScore(score int) { + redisClient := m.getRedisClient() + if redisClient != nil { + redisClient.Set(m.ctx, "score", score, 0) + } +} + +// HandleHTTP handles HTTP requests for high scores +func (m *Manager) HandleHTTP(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + score := m.GetScore() + w.Write([]byte(strconv.Itoa(score))) + case http.MethodPost: + newScore, _ := io.ReadAll(r.Body) + iNewScore, _ := strconv.Atoi(string(newScore)) + iOldScore := m.GetScore() + if iNewScore > iOldScore { + m.SetScore(iNewScore) + w.Write(newScore) + } else { + w.Write([]byte(strconv.Itoa(iOldScore))) + } + case http.MethodPut: + newScore, _ := io.ReadAll(r.Body) + iNewScore, _ := strconv.Atoi(string(newScore)) + m.SetScore(iNewScore) + w.Write(newScore) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +// GetRedisInfo returns Redis connection information +func (m *Manager) GetRedisInfo() (host, port, status string) { + if m.useDirectRedis { + host = m.redisHost + port = m.redisPort + } else if m.HVSClient != nil { + host, _ = m.HVSClient.GetSecret(m.appName, "redis_ip") + port, _ = m.HVSClient.GetSecret(m.appName, "redis_port") + } + + status = "No connection" + redisClient := m.getRedisClient() + if redisClient != nil { + pingResp := redisClient.Ping(m.ctx) + status = pingResp.String() + } + + return host, port, status +} diff --git a/internal/hvs_client.go b/internal/hvs_client/hvs_client.go similarity index 98% rename from internal/hvs_client.go rename to internal/hvs_client/hvs_client.go index 8208481..7506ac2 100644 --- a/internal/hvs_client.go +++ b/internal/hvs_client/hvs_client.go @@ -1,4 +1,4 @@ -package terraminogo +package hvs_client import ( "log" diff --git a/main.go b/main.go index 3e32e9c..b75bf23 100644 --- a/main.go +++ b/main.go @@ -4,62 +4,58 @@ import ( "context" "errors" "fmt" - "io" "log" "net/http" "os" - "strconv" "strings" "text/template" - terraminogo "github.com/hashicorp-education/terraminogo/internal" - "github.com/redis/go-redis/v9" + "github.com/hashicorp-education/terraminogo/internal/highscore" + "github.com/hashicorp-education/terraminogo/internal/hvs_client" ) -type TerraminoData struct { - HVSClient *terraminogo.HVSClient - redisClient *redis.Client - ctx context.Context - appName string - useDirectRedis bool - redisHost string - redisPort string - redisPassword string +type TerraminoServer struct { + highScoreManager *highscore.Manager } func main() { - t := &TerraminoData{} - t.ctx = context.Background() + ctx := context.Background() - // Check if direct Redis connection is configured + // Get application name + appName, envExists := os.LookupEnv("APP_NAME") + if !envExists { + appName = "terramino" + } + + // Initialize high score manager + server := &TerraminoServer{ + highScoreManager: highscore.NewManager(ctx, appName), + } + + // Configure Redis connection redisHost, hasRedisHost := os.LookupEnv("REDIS_HOST") redisPort, hasRedisPort := os.LookupEnv("REDIS_PORT") if hasRedisHost && hasRedisPort { - t.useDirectRedis = true - t.redisHost = redisHost - t.redisPort = redisPort - t.redisPassword = os.Getenv("REDIS_PASSWORD") - t.HVSClient = nil + // Use direct Redis connection + server.highScoreManager.ConfigureRedis( + redisHost, + redisPort, + os.Getenv("REDIS_PASSWORD"), + ) } else { - t.useDirectRedis = false - t.HVSClient = terraminogo.NewHVSClient() + // Use HVS for Redis configuration + server.highScoreManager.ConfigureHVS(hvs_client.NewHVSClient()) } - t.redisClient = nil - - appName, envExists := os.LookupEnv("APP_NAME") - if !envExists { - appName = "terramino" - } - t.appName = appName - + // Set up HTTP routes http.HandleFunc("/", indexHandler) http.HandleFunc("/env", envHandler) - http.HandleFunc("/score", t.highScoreHandler) - http.HandleFunc("/redis", t.redisHandler) + http.HandleFunc("/score", server.highScoreManager.HandleHTTP) + http.HandleFunc("/redis", server.redisHandler) http.HandleFunc("/{path}", pathHandler) + // Start server envPort, envPortExists := os.LookupEnv("TERRAMINO_PORT") if !envPortExists { envPort = "8080" @@ -104,96 +100,6 @@ func pathHandler(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, filePath) } -func (t *TerraminoData) highScoreHandler(w http.ResponseWriter, r *http.Request) { - if r.Method == "GET" { - score := t.GetHighScore() - w.Write([]byte(strconv.Itoa(score))) - } else if r.Method == "POST" { - newScore, _ := io.ReadAll(r.Body) - iNewScore, _ := strconv.Atoi(string(newScore)) - iOldScore := t.GetHighScore() - if iNewScore > iOldScore { - t.SetHighScore(iNewScore) - w.Write(newScore) - } else { - w.Write([]byte(strconv.Itoa(iOldScore))) - } - } else if r.Method == "PUT" { - newScore, _ := io.ReadAll(r.Body) - iNewScore, _ := strconv.Atoi(string(newScore)) - t.SetHighScore(iNewScore) - w.Write(newScore) - } -} - -func (t *TerraminoData) getRedisClient() *redis.Client { - if t.redisClient != nil { - // We have an existing connection, make sure it's still valid - pingResp := t.redisClient.Ping(t.ctx) - if pingResp.Err() == nil { - // Connection is valid, return client - return t.redisClient - } - } - - // Either we don't have a connection, or it's no longer valid - // Create a new client - var redisIP, redisPort, redisPassword string - var err error - - if t.useDirectRedis { - redisIP = t.redisHost - redisPort = t.redisPort - redisPassword = t.redisPassword - } else { - // Check for connection info in HVS - redisIP, err = t.HVSClient.GetSecret(t.appName, "redis_ip") - if err != nil { - // No Redis server is available - t.redisClient = nil - return nil - } - redisPort, _ = t.HVSClient.GetSecret(t.appName, "redis_port") - redisPassword, _ = t.HVSClient.GetSecret(t.appName, "redis_password") - } - - t.redisClient = redis.NewClient(&redis.Options{ - Addr: fmt.Sprintf("%s:%s", redisIP, redisPort), - Password: redisPassword, - DB: 0, - }) - - // Check connection - pingResp := t.redisClient.Ping(t.ctx) - if pingResp.Err() != nil { - // Error connecting to the server - log.Println(pingResp.Err()) - return nil - } - - return t.redisClient -} - -func (t *TerraminoData) GetHighScore() int { - redisClient := t.getRedisClient() - if redisClient != nil { - val, err := redisClient.Get(t.ctx, "score").Result() - if err == nil { - iVal, _ := strconv.Atoi(val) - return iVal - } - } - - return 0 -} - -func (t *TerraminoData) SetHighScore(score int) { - redisClient := t.getRedisClient() - if redisClient != nil { - redisClient.Set(t.ctx, "score", score, 0) - } -} - // Lookup requested file, return an error if it // does not exist func fileLookup(file string) (string, error) { @@ -223,16 +129,7 @@ func envHandler(w http.ResponseWriter, r *http.Request) { w.Write([]byte(out)) } -func (t *TerraminoData) redisHandler(w http.ResponseWriter, r *http.Request) { - redisHost, _ := t.HVSClient.GetSecret(t.appName, "redis_ip") - redisPort, _ := t.HVSClient.GetSecret(t.appName, "redis_port") - - redisPing := "No connection" - redisClient := t.getRedisClient() - if redisClient != nil { - pingResp := redisClient.Ping(t.ctx) - redisPing = pingResp.String() - } - - fmt.Fprintf(w, "redis_host=%s\nredis_port=%s\n\nConnection: %s", redisHost, redisPort, redisPing) +func (s *TerraminoServer) redisHandler(w http.ResponseWriter, r *http.Request) { + host, port, status := s.highScoreManager.GetRedisInfo() + fmt.Fprintf(w, "redis_host=%s\nredis_port=%s\n\nConnection: %s", host, port, status) } From 53a1e4dca5e2b7537df5f853112849525c54a4f2 Mon Sep 17 00:00:00 2001 From: Tu Nguyen Date: Tue, 7 Jan 2025 16:11:58 -0800 Subject: [PATCH 3/9] remove serving files from backend since nginx handles that --- main.go | 50 +------------------------------------------------- 1 file changed, 1 insertion(+), 49 deletions(-) diff --git a/main.go b/main.go index b75bf23..6d6940f 100644 --- a/main.go +++ b/main.go @@ -2,13 +2,11 @@ package main import ( "context" - "errors" "fmt" "log" "net/http" "os" "strings" - "text/template" "github.com/hashicorp-education/terraminogo/internal/highscore" "github.com/hashicorp-education/terraminogo/internal/hvs_client" @@ -49,11 +47,9 @@ func main() { } // Set up HTTP routes - http.HandleFunc("/", indexHandler) http.HandleFunc("/env", envHandler) - http.HandleFunc("/score", server.highScoreManager.HandleHTTP) http.HandleFunc("/redis", server.redisHandler) - http.HandleFunc("/{path}", pathHandler) + http.HandleFunc("/score", server.highScoreManager.HandleHTTP) // Start server envPort, envPortExists := os.LookupEnv("TERRAMINO_PORT") @@ -69,50 +65,6 @@ func main() { } } -// Parse and serve index template -func indexHandler(w http.ResponseWriter, r *http.Request) { - t, err := template.ParseFiles("web/index.html") - if err != nil { - log.Fatal(err) - } - - err = t.ExecuteTemplate(w, "index.html", nil) - if err != nil { - log.Fatal(err) - } -} - -// Handle non-template files -func pathHandler(w http.ResponseWriter, r *http.Request) { - filePath, err := fileLookup(r.PathValue("path")) - if err != nil { - // User requested a file that does not exist - // Return 404 - if errors.Is(err, os.ErrNotExist) { - w.WriteHeader(404) - return - } else { - // Unknown error - log.Fatal(err) - } - } - - http.ServeFile(w, r, filePath) -} - -// Lookup requested file, return an error if it -// does not exist -func fileLookup(file string) (string, error) { - fullPath := fmt.Sprintf("web/%s", file) - _, err := os.Stat(fullPath) - - if err != nil { - return "", err - } else { - return fullPath, nil - } -} - // DEBUG: Print all runtime environment variables that start with "HCP_" func envHandler(w http.ResponseWriter, r *http.Request) { out := "" From 964659736d8c1070473292d42bbb7a83c60d83a3 Mon Sep 17 00:00:00 2001 From: Tu Nguyen Date: Tue, 7 Jan 2025 16:16:06 -0800 Subject: [PATCH 4/9] add service down msg if running frontend alone --- nginx.conf | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/nginx.conf b/nginx.conf index 8139ae9..001ec03 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,3 +1,7 @@ +map $http_host $backend_upstream { + default "http://backend:8080"; +} + server { listen 80; server_name localhost; @@ -9,8 +13,18 @@ server { } location /score { - proxy_pass http://backend:8080; + proxy_pass $backend_upstream; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + + # Add error handling for when backend is down + proxy_intercept_errors on; + error_page 502 503 504 = @backend_down; + } + + location @backend_down { + # Return a 200 status with a JSON response + default_type application/json; + return 200 'SVC_DOWN'; } } \ No newline at end of file From 7fcdce7d4f0bb5d70c14de63e9b0cbf28773a043 Mon Sep 17 00:00:00 2001 From: Tu Nguyen Date: Tue, 7 Jan 2025 16:43:33 -0800 Subject: [PATCH 5/9] add demo endpoint --- README.md | 2 +- main.go | 3 +++ nginx.conf | 10 +++++++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c30bbbd..98a3cd7 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A HashiCorp-themed Tetris-like game with web and CLI interfaces, built in Go. docker compose up # Play in browser -open http://localhost +open http://localhost:8080 # Play in terminal docker compose exec -it backend ./terramino-cli diff --git a/main.go b/main.go index 6d6940f..cc894a4 100644 --- a/main.go +++ b/main.go @@ -50,6 +50,9 @@ func main() { http.HandleFunc("/env", envHandler) http.HandleFunc("/redis", server.redisHandler) http.HandleFunc("/score", server.highScoreManager.HandleHTTP) + http.HandleFunc("/info", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("Terramino - HashiCorp Demo App\nhttps://developer.hashicorp.com/")) + }) // Start server envPort, envPortExists := os.LookupEnv("TERRAMINO_PORT") diff --git a/nginx.conf b/nginx.conf index 001ec03..555449f 100644 --- a/nginx.conf +++ b/nginx.conf @@ -3,7 +3,7 @@ map $http_host $backend_upstream { } server { - listen 80; + listen 8080; server_name localhost; location / { @@ -22,6 +22,14 @@ server { error_page 502 503 504 = @backend_down; } + location /info { + proxy_pass $backend_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_intercept_errors on; + error_page 502 503 504 = @backend_down; + } + location @backend_down { # Return a 200 status with a JSON response default_type application/json; From a599d151beda6736c7406556cab5576a07079326 Mon Sep 17 00:00:00 2001 From: Tu Nguyen Date: Tue, 7 Jan 2025 17:19:53 -0800 Subject: [PATCH 6/9] move frontend port to 8081 --- Dockerfile.frontend | 2 +- README.md | 2 +- docker-compose.yml | 2 +- nginx.conf | 16 +++------------- 4 files changed, 6 insertions(+), 16 deletions(-) diff --git a/Dockerfile.frontend b/Dockerfile.frontend index 1e62419..dcd1858 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -3,4 +3,4 @@ FROM nginx:alpine COPY web /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf -EXPOSE 80 \ No newline at end of file +EXPOSE 8081 \ No newline at end of file diff --git a/README.md b/README.md index 98a3cd7..012ab21 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A HashiCorp-themed Tetris-like game with web and CLI interfaces, built in Go. docker compose up # Play in browser -open http://localhost:8080 +open http://localhost:8081 # Play in terminal docker compose exec -it backend ./terramino-cli diff --git a/docker-compose.yml b/docker-compose.yml index 18a35b7..cd6eaa8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: context: . dockerfile: Dockerfile.frontend ports: - - "80:80" + - "8081:8081" depends_on: - backend diff --git a/nginx.conf b/nginx.conf index 555449f..7c83c81 100644 --- a/nginx.conf +++ b/nginx.conf @@ -3,7 +3,7 @@ map $http_host $backend_upstream { } server { - listen 8080; + listen 8081; server_name localhost; location / { @@ -12,17 +12,8 @@ server { try_files $uri $uri/ /index.html; } - location /score { - proxy_pass $backend_upstream; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - - # Add error handling for when backend is down - proxy_intercept_errors on; - error_page 502 503 504 = @backend_down; - } - - location /info { + # Combined location block for all backend endpoints + location ~ ^/(redis|score|info) { proxy_pass $backend_upstream; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -31,7 +22,6 @@ server { } location @backend_down { - # Return a 200 status with a JSON response default_type application/json; return 200 'SVC_DOWN'; } From 7a71ff97b6cc9f6f4bf794b1f3f5b7ca1e18997d Mon Sep 17 00:00:00 2001 From: Tu Nguyen Date: Wed, 8 Jan 2025 06:59:15 -0800 Subject: [PATCH 7/9] update nginx to bind on all addresses, remove backend dependency --- docker-compose.yml | 2 -- main.go | 2 +- nginx.conf | 20 +++++++++++++------- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index cd6eaa8..fc57a65 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,8 +5,6 @@ services: dockerfile: Dockerfile.frontend ports: - "8081:8081" - depends_on: - - backend backend: build: diff --git a/main.go b/main.go index cc894a4..22c748d 100644 --- a/main.go +++ b/main.go @@ -51,7 +51,7 @@ func main() { http.HandleFunc("/redis", server.redisHandler) http.HandleFunc("/score", server.highScoreManager.HandleHTTP) http.HandleFunc("/info", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("Terramino - HashiCorp Demo App\nhttps://developer.hashicorp.com/")) + w.Write([]byte("Terramino - HashiCorp Demo App\nhttps://developer.hashicorp.com/\n")) }) // Start server diff --git a/nginx.conf b/nginx.conf index 7c83c81..15e55f8 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,10 +1,13 @@ -map $http_host $backend_upstream { - default "http://backend:8080"; -} - server { listen 8081; - server_name localhost; + server_name 0.0.0.0; + + # Add a health check endpoint that always returns OK + location /health { + access_log off; + add_header Content-Type application/json; + return 200 '{"status":"OK"}'; + } location / { root /usr/share/nginx/html; @@ -14,15 +17,18 @@ server { # Combined location block for all backend endpoints location ~ ^/(redis|score|info) { - proxy_pass $backend_upstream; + proxy_pass http://backend:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_connect_timeout 5s; # Add timeouts + proxy_read_timeout 5s; + proxy_send_timeout 5s; proxy_intercept_errors on; error_page 502 503 504 = @backend_down; } location @backend_down { default_type application/json; - return 200 'SVC_DOWN'; + return 503 'SVC_DOWN'; } } \ No newline at end of file From 626a58f0316eb331dd5e7ce5a50153b3f1cbe3ed Mon Sep 17 00:00:00 2001 From: Tu Nguyen Date: Wed, 8 Jan 2025 07:01:57 -0800 Subject: [PATCH 8/9] move info to / --- main.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/main.go b/main.go index 22c748d..8d0087c 100644 --- a/main.go +++ b/main.go @@ -47,12 +47,12 @@ func main() { } // Set up HTTP routes + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("Terramino - HashiCorp Demo App\nhttps://developer.hashicorp.com/\n")) + }) http.HandleFunc("/env", envHandler) http.HandleFunc("/redis", server.redisHandler) http.HandleFunc("/score", server.highScoreManager.HandleHTTP) - http.HandleFunc("/info", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("Terramino - HashiCorp Demo App\nhttps://developer.hashicorp.com/\n")) - }) // Start server envPort, envPortExists := os.LookupEnv("TERRAMINO_PORT") From ee60053206bbb513501368d884a59b6098f43f09 Mon Sep 17 00:00:00 2001 From: Tu Nguyen Date: Thu, 9 Jan 2025 09:47:26 -0800 Subject: [PATCH 9/9] one second timeouts --- nginx.conf | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nginx.conf b/nginx.conf index 15e55f8..db5717b 100644 --- a/nginx.conf +++ b/nginx.conf @@ -20,9 +20,9 @@ server { proxy_pass http://backend:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - proxy_connect_timeout 5s; # Add timeouts - proxy_read_timeout 5s; - proxy_send_timeout 5s; + proxy_connect_timeout 1s; # Add one second timeouts (no timeout) + proxy_read_timeout 1s; + proxy_send_timeout 1s; proxy_intercept_errors on; error_page 502 503 504 = @backend_down; }