diff --git a/Dockerfile.backend b/Dockerfile.backend new file mode 100644 index 0000000..5ae6fc4 --- /dev/null +++ b/Dockerfile.backend @@ -0,0 +1,18 @@ +FROM golang:1.22 + +WORKDIR /app + +COPY go.mod go.sum ./ +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 + +# 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/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..dcd1858 --- /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 8081 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..012ab21 --- /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:8081 + +# 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 new file mode 100644 index 0000000..fc57a65 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + frontend: + build: + context: . + dockerfile: Dockerfile.frontend + ports: + - "8081:8081" + + 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/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 712e342..8d0087c 100644 --- a/main.go +++ b/main.go @@ -2,45 +2,59 @@ package main 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 +type TerraminoServer struct { + highScoreManager *highscore.Manager } func main() { - t := &TerraminoData{} - t.HVSClient = terraminogo.NewHVSClient() - t.redisClient = nil - t.ctx = context.Background() + ctx := context.Background() + // Get application name appName, envExists := os.LookupEnv("APP_NAME") if !envExists { appName = "terramino" } - t.appName = appName - http.HandleFunc("/", indexHandler) + // 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 { + // Use direct Redis connection + server.highScoreManager.ConfigureRedis( + redisHost, + redisPort, + os.Getenv("REDIS_PASSWORD"), + ) + } else { + // Use HVS for Redis configuration + server.highScoreManager.ConfigureHVS(hvs_client.NewHVSClient()) + } + + // 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("/score", t.highScoreHandler) - http.HandleFunc("/redis", t.redisHandler) - http.HandleFunc("/{path}", pathHandler) + http.HandleFunc("/redis", server.redisHandler) + http.HandleFunc("/score", server.highScoreManager.HandleHTTP) + // Start server envPort, envPortExists := os.LookupEnv("TERRAMINO_PORT") if !envPortExists { envPort = "8080" @@ -54,131 +68,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) -} - -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 - - // 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) { - 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 := "" @@ -195,16 +84,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) } diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..db5717b --- /dev/null +++ b/nginx.conf @@ -0,0 +1,34 @@ +server { + listen 8081; + 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; + index index.html; + try_files $uri $uri/ /index.html; + } + + # Combined location block for all backend endpoints + location ~ ^/(redis|score|info) { + proxy_pass http://backend:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + 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; + } + + location @backend_down { + default_type application/json; + return 503 'SVC_DOWN'; + } +} \ No newline at end of file