DEV 1404: Support streaming CLI games to the browser board (#88)

* add minimal support for serving a game to the board UI

* refactor into new board package

* support reporting errors and author names to board

* support passing an alternate board URL

* avoid using IPv6 for local URL

* use rules.Point instead of a custom Point type for board package

* use zero for generic communication error code in cli

* rename createGameEvent to buildFrameEvent

* tests for conversion from boardState/snakeState to game frame
This commit is contained in:
Rob O'Dwyer 2022-06-28 16:17:44 -07:00 committed by GitHub
parent f6c3ed0791
commit a451cda9c4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 504 additions and 22 deletions

72
board/api.go Normal file
View file

@ -0,0 +1,72 @@
package board
import (
"github.com/BattlesnakeOfficial/rules"
)
// Types used to implement the JSON API expected by the board client.
// JSON structure returned by the game status endpoint.
type Game struct {
ID string `json:"ID"`
Status string `json:"Status"`
Width int `json:"Width"`
Height int `json:"Height"`
Ruleset map[string]string `json:"Ruleset"`
SnakeTimeout int `json:"SnakeTimeout"`
Source string `json:"Source"`
RulesetName string `json:"RulesetName"`
RulesStages []string `json:"RulesStages"`
Map string `json:"Map"`
}
// The websocket stream has support for returning different types of events, along with a "type" attribute.
type GameEventType string
const (
EVENT_TYPE_FRAME GameEventType = "frame"
EVENT_TYPE_GAME_END GameEventType = "game_end"
)
// Top-level JSON structure sent in each websocket frame.
type GameEvent struct {
EventType GameEventType `json:"Type"`
Data interface{} `json:"Data"`
}
// Represents a single turn in the game.
type GameFrame struct {
Turn int `json:"Turn"`
Snakes []Snake `json:"Snakes"`
Food []rules.Point `json:"Food"`
Hazards []rules.Point `json:"Hazards"`
}
type GameEnd struct {
Game Game `json:"game"`
}
type Snake struct {
ID string `json:"ID"`
Name string `json:"Name"`
Body []rules.Point `json:"Body"`
Health int `json:"Health"`
Death *Death `json:"Death"`
Color string `json:"Color"`
HeadType string `json:"HeadType"`
TailType string `json:"TailType"`
Latency string `json:"Latency"`
Shout string `json:"Shout"`
Squad string `json:"Squad"`
Author string `json:"Author"`
StatusCode int `json:"StatusCode"`
Error string `json:"Error"`
IsBot bool `json:"IsBot"`
IsEnvironment bool `json:"IsEnvironment"`
}
type Death struct {
Cause string `json:"Cause"`
Turn int `json:"Turn"`
EliminatedBy string `json:"EliminatedBy"`
}

130
board/server.go Normal file
View file

@ -0,0 +1,130 @@
package board
import (
"context"
"encoding/json"
"log"
"net"
"net/http"
"github.com/gorilla/websocket"
"github.com/rs/cors"
)
// A minimal server capable of handling the requests from a single browser client running the board viewer.
type BoardServer struct {
game Game
events chan GameEvent // channel for sending events from the game runner to the browser client
done chan bool // channel for signalling (via closing) that all events have been sent to the browser client
httpServer *http.Server
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
func NewBoardServer(game Game) *BoardServer {
mux := http.NewServeMux()
server := &BoardServer{
game: game,
events: make(chan GameEvent, 1000), // buffered channel to allow game to run ahead of browser client
done: make(chan bool),
httpServer: &http.Server{
Handler: cors.Default().Handler(mux),
},
}
mux.HandleFunc("/games/"+game.ID, server.handleGame)
mux.HandleFunc("/games/"+game.ID+"/events", server.handleWebsocket)
return server
}
// Handle the /games/:id request made by the board to fetch the game metadata.
func (server *BoardServer) handleGame(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "application/json")
err := json.NewEncoder(w).Encode(struct {
Game Game
}{server.game})
if err != nil {
log.Printf("Unable to serialize game: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
}
// Handle the /games/:id/events websocket request made by the board to receive game events.
func (server *BoardServer) handleWebsocket(w http.ResponseWriter, r *http.Request) {
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("Unable to upgrade connection: %v", err)
return
}
defer func() {
err = ws.Close()
if err != nil {
log.Printf("Unable to close websocket stream")
}
}()
for event := range server.events {
jsonStr, err := json.Marshal(event)
if err != nil {
log.Printf("Unable to serialize event for websocket: %v", err)
}
err = ws.WriteMessage(websocket.TextMessage, jsonStr)
if err != nil {
log.Printf("Unable to write to websocket: %v", err)
break
}
}
log.Printf("Finished writing all game events, signalling game server to stop")
close(server.done)
log.Printf("Sending websocket close message")
err = ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
if err != nil {
log.Printf("Problem closing websocket: %v", err)
}
}
func (server *BoardServer) Listen() (string, error) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", err
}
go func() {
err = server.httpServer.Serve(listener)
if err != http.ErrServerClosed {
log.Printf("Error in board HTTP server: %v", err)
}
}()
url := "http://" + listener.Addr().String()
return url, nil
}
func (server *BoardServer) Shutdown() {
close(server.events)
log.Printf("Waiting for websocket clients to finish")
<-server.done
log.Printf("Server is done, exiting")
err := server.httpServer.Shutdown(context.Background())
if err != nil {
log.Printf("Error shutting down HTTP server: %v", err)
}
}
func (server *BoardServer) SendEvent(event GameEvent) {
server.events <- event
}

View file

@ -16,22 +16,28 @@ import (
"time" "time"
"github.com/BattlesnakeOfficial/rules" "github.com/BattlesnakeOfficial/rules"
"github.com/BattlesnakeOfficial/rules/board"
"github.com/BattlesnakeOfficial/rules/client" "github.com/BattlesnakeOfficial/rules/client"
"github.com/BattlesnakeOfficial/rules/maps" "github.com/BattlesnakeOfficial/rules/maps"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/pkg/browser"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// Used to store state for each SnakeState while running a local game // Used to store state for each SnakeState while running a local game
type SnakeState struct { type SnakeState struct {
URL string URL string
Name string Name string
ID string ID string
LastMove string LastMove string
Character rune Character rune
Color string Color string
Head string Head string
Tail string Tail string
Author string
Version string
Error error
StatusCode int
} }
type GameState struct { type GameState struct {
@ -51,6 +57,8 @@ type GameState struct {
TurnDelay int TurnDelay int
DebugRequests bool DebugRequests bool
Output string Output string
ViewInBrowser bool
BoardURL string
FoodSpawnChance int FoodSpawnChance int
MinimumFood int MinimumFood int
HazardDamagePerTurn int HazardDamagePerTurn int
@ -92,6 +100,8 @@ func NewPlayCommand() *cobra.Command {
playCmd.Flags().IntVarP(&gameState.TurnDuration, "duration", "D", 0, "Minimum Turn Duration in Milliseconds") playCmd.Flags().IntVarP(&gameState.TurnDuration, "duration", "D", 0, "Minimum Turn Duration in Milliseconds")
playCmd.Flags().BoolVar(&gameState.DebugRequests, "debug-requests", false, "Log body of all requests sent") playCmd.Flags().BoolVar(&gameState.DebugRequests, "debug-requests", false, "Log body of all requests sent")
playCmd.Flags().StringVarP(&gameState.Output, "output", "o", "", "File path to output game state to. Existing files will be overwritten") playCmd.Flags().StringVarP(&gameState.Output, "output", "o", "", "File path to output game state to. Existing files will be overwritten")
playCmd.Flags().BoolVar(&gameState.ViewInBrowser, "browser", false, "View the game in the browser using the Battlesnake game board")
playCmd.Flags().StringVar(&gameState.BoardURL, "board-url", "https://board.battlesnake.com", "Base URL for the game board when using --browser")
playCmd.Flags().IntVar(&gameState.FoodSpawnChance, "foodSpawnChance", 15, "Percentage chance of spawning a new food every round") playCmd.Flags().IntVar(&gameState.FoodSpawnChance, "foodSpawnChance", 15, "Percentage chance of spawning a new food every round")
playCmd.Flags().IntVar(&gameState.MinimumFood, "minimumFood", 1, "Minimum food to keep on the board every turn") playCmd.Flags().IntVar(&gameState.MinimumFood, "minimumFood", 1, "Minimum food to keep on the board every turn")
@ -167,6 +177,40 @@ func (gameState *GameState) Run() {
gameState.printMap(boardState) gameState.printMap(boardState)
} }
boardGame := board.Game{
ID: gameState.gameID,
Status: "running",
Width: gameState.Width,
Height: gameState.Height,
Ruleset: map[string]string{
rules.ParamGameType: gameState.GameType,
},
RulesetName: gameState.GameType,
RulesStages: []string{},
Map: gameState.MapName,
}
boardServer := board.NewBoardServer(boardGame)
if gameState.ViewInBrowser {
serverURL, err := boardServer.Listen()
if err != nil {
log.Fatalf("Error starting HTTP server: %v", err)
}
log.Printf("Board server listening on %s", serverURL)
boardURL := fmt.Sprintf(gameState.BoardURL+"?engine=%s&game=%s&autoplay=true", serverURL, gameState.gameID)
log.Printf("Opening board URL: %s", boardURL)
if err := browser.OpenURL(boardURL); err != nil {
log.Printf("Failed to open browser: %v", err)
}
}
if gameState.ViewInBrowser {
// send turn zero to websocket server
boardServer.SendEvent(gameState.buildFrameEvent(boardState))
}
var endTime time.Time var endTime time.Time
for v := false; !v; v, _ = gameState.ruleset.IsGameOver(boardState) { for v := false; !v; v, _ = gameState.ruleset.IsGameOver(boardState) {
if gameState.TurnDuration > 0 { if gameState.TurnDuration > 0 {
@ -205,6 +249,9 @@ func (gameState *GameState) Run() {
time.Sleep(time.Until(endTime)) time.Sleep(time.Until(endTime))
} }
if gameState.ViewInBrowser {
boardServer.SendEvent(gameState.buildFrameEvent(boardState))
}
} }
isDraw := true isDraw := true
@ -239,6 +286,13 @@ func (gameState *GameState) Run() {
} }
} }
if gameState.ViewInBrowser {
boardServer.SendEvent(board.GameEvent{
EventType: board.EVENT_TYPE_GAME_END,
Data: boardGame,
})
}
if exportGame { if exportGame {
err := gameExporter.FlushToFile(gameState.Output, "JSONL") err := gameExporter.FlushToFile(gameState.Output, "JSONL")
if err != nil { if err != nil {
@ -246,6 +300,10 @@ func (gameState *GameState) Run() {
os.Exit(1) os.Exit(1)
} }
} }
if gameState.ViewInBrowser {
boardServer.Shutdown()
}
} }
func (gameState *GameState) initializeBoardFromArgs() *rules.BoardState { func (gameState *GameState) initializeBoardFromArgs() *rules.BoardState {
@ -449,26 +507,39 @@ func (gameState *GameState) buildSnakesFromOptions() map[string]SnakeState {
snakeState := SnakeState{ snakeState := SnakeState{
Name: snakeName, URL: snakeURL, ID: id, LastMove: "up", Character: bodyChars[i%8], Name: snakeName, URL: snakeURL, ID: id, LastMove: "up", Character: bodyChars[i%8],
} }
var snakeErr error
res, err := gameState.httpClient.Get(snakeURL) res, err := gameState.httpClient.Get(snakeURL)
if err != nil { if err != nil {
log.Printf("[WARN]: Request to %v failed: %v", snakeURL, err) log.Printf("[WARN]: Request to %v failed: %v", snakeURL, err)
} else if res.Body != nil { snakeErr = err
defer res.Body.Close() } else {
body, readErr := ioutil.ReadAll(res.Body) snakeState.StatusCode = res.StatusCode
if readErr != nil {
log.Fatal(readErr)
}
pingResponse := client.SnakeMetadataResponse{} if res.Body != nil {
jsonErr := json.Unmarshal(body, &pingResponse) defer res.Body.Close()
if jsonErr != nil { body, readErr := ioutil.ReadAll(res.Body)
log.Printf("Error reading response from %v: %v", snakeURL, jsonErr) if readErr != nil {
} else { log.Fatal(readErr)
snakeState.Head = pingResponse.Head }
snakeState.Tail = pingResponse.Tail
snakeState.Color = pingResponse.Color pingResponse := client.SnakeMetadataResponse{}
jsonErr := json.Unmarshal(body, &pingResponse)
if jsonErr != nil {
snakeErr = jsonErr
log.Printf("Error reading response from %v: %v", snakeURL, jsonErr)
} else {
snakeState.Head = pingResponse.Head
snakeState.Tail = pingResponse.Tail
snakeState.Color = pingResponse.Color
snakeState.Author = pingResponse.Author
snakeState.Version = pingResponse.Version
}
} }
} }
if snakeErr != nil {
snakeState.Error = snakeErr
}
snakes[snakeState.ID] = snakeState snakes[snakeState.ID] = snakeState
} }
return snakes return snakes
@ -546,6 +617,59 @@ func (gameState *GameState) printMap(boardState *rules.BoardState) {
log.Print(o.String()) log.Print(o.String())
} }
func (gameState *GameState) buildFrameEvent(boardState *rules.BoardState) board.GameEvent {
snakes := []board.Snake{}
for _, snake := range boardState.Snakes {
snakeState := gameState.snakeStates[snake.ID]
convertedSnake := board.Snake{
ID: snake.ID,
Name: snakeState.Name,
Body: snake.Body,
Health: snake.Health,
Color: snakeState.Color,
HeadType: snakeState.Head,
TailType: snakeState.Tail,
Author: snakeState.Author,
StatusCode: snakeState.StatusCode,
IsBot: false,
IsEnvironment: false,
// Not supporting local latency for now - there are better ways to test performance locally
Latency: "1",
}
if snakeState.Error != nil {
// Instead of trying to keep in sync with the production engine's
// error detection and messages, just show a generic error and rely
// on the CLI logs to show what really happened.
convertedSnake.Error = "0:Error communicating with server"
} else if snakeState.StatusCode != http.StatusOK {
convertedSnake.Error = fmt.Sprintf("7:Bad HTTP status code %d", snakeState.StatusCode)
}
if snake.EliminatedCause != rules.NotEliminated {
convertedSnake.Death = &board.Death{
Cause: snake.EliminatedCause,
Turn: snake.EliminatedOnTurn,
EliminatedBy: snake.EliminatedBy,
}
}
snakes = append(snakes, convertedSnake)
}
gameFrame := board.GameFrame{
Turn: boardState.Turn,
Snakes: snakes,
Food: boardState.Food,
Hazards: boardState.Hazards,
}
return board.GameEvent{
EventType: board.EVENT_TYPE_FRAME,
Data: gameFrame,
}
}
func serialiseSnakeRequest(snakeRequest client.SnakeRequest) []byte { func serialiseSnakeRequest(snakeRequest client.SnakeRequest) []byte {
requestJSON, err := json.Marshal(snakeRequest) requestJSON, err := json.Marshal(snakeRequest)
if err != nil { if err != nil {

View file

@ -5,6 +5,7 @@ import (
"testing" "testing"
"github.com/BattlesnakeOfficial/rules" "github.com/BattlesnakeOfficial/rules"
"github.com/BattlesnakeOfficial/rules/board"
"github.com/BattlesnakeOfficial/rules/client" "github.com/BattlesnakeOfficial/rules/client"
"github.com/BattlesnakeOfficial/rules/test" "github.com/BattlesnakeOfficial/rules/test"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -221,3 +222,149 @@ func TestConvertRulesSnakes(t *testing.T) {
}) })
} }
} }
func TestBuildFrameEvent(t *testing.T) {
tests := []struct {
name string
boardState *rules.BoardState
snakeStates map[string]SnakeState
expected board.GameEvent
}{
{
name: "empty",
boardState: rules.NewBoardState(11, 11),
snakeStates: map[string]SnakeState{},
expected: board.GameEvent{
EventType: board.EVENT_TYPE_FRAME,
Data: board.GameFrame{
Turn: 0,
Snakes: []board.Snake{},
Food: []rules.Point{},
Hazards: []rules.Point{},
},
},
},
{
name: "snake fields",
boardState: &rules.BoardState{
Turn: 99,
Height: 19,
Width: 25,
Food: []rules.Point{{X: 9, Y: 4}},
Snakes: []rules.Snake{
{
ID: "1",
Body: []rules.Point{
{X: 9, Y: 4},
{X: 8, Y: 4},
{X: 7, Y: 4},
},
Health: 97,
EliminatedCause: rules.EliminatedBySelfCollision,
EliminatedOnTurn: 45,
EliminatedBy: "1",
},
},
Hazards: []rules.Point{{X: 8, Y: 6}},
},
snakeStates: map[string]SnakeState{
"1": {
URL: "http://example.com",
Name: "One",
ID: "1",
LastMove: "left",
Color: "#ff00ff",
Head: "silly",
Tail: "default",
Author: "AUTHOR",
Version: "1.5",
Error: nil,
StatusCode: 200,
},
},
expected: board.GameEvent{
EventType: board.EVENT_TYPE_FRAME,
Data: board.GameFrame{
Turn: 99,
Snakes: []board.Snake{
{
ID: "1",
Name: "One",
Body: []rules.Point{{X: 9, Y: 4}, {X: 8, Y: 4}, {X: 7, Y: 4}},
Health: 97,
Death: &board.Death{
Cause: rules.EliminatedBySelfCollision,
Turn: 45,
EliminatedBy: "1",
},
Color: "#ff00ff",
HeadType: "silly",
TailType: "default",
Latency: "1",
Author: "AUTHOR",
StatusCode: 200,
Error: "",
IsBot: false,
IsEnvironment: false,
},
},
Food: []rules.Point{{X: 9, Y: 4}},
Hazards: []rules.Point{{X: 8, Y: 6}},
},
},
},
{
name: "snake errors",
boardState: &rules.BoardState{
Height: 19,
Width: 25,
Snakes: []rules.Snake{
{
ID: "bad_status",
},
{
ID: "connection_error",
},
},
},
snakeStates: map[string]SnakeState{
"bad_status": {
StatusCode: 504,
},
"connection_error": {
Error: fmt.Errorf("error connecting to host"),
},
},
expected: board.GameEvent{
EventType: board.EVENT_TYPE_FRAME,
Data: board.GameFrame{
Snakes: []board.Snake{
{
ID: "bad_status",
Latency: "1",
StatusCode: 504,
Error: "7:Bad HTTP status code 504",
},
{
ID: "connection_error",
Latency: "1",
StatusCode: 0,
Error: "0:Error communicating with server",
},
},
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
gameState := GameState{
snakeStates: test.snakeStates,
}
actual := gameState.buildFrameEvent(test.boardState)
require.Equalf(t, test.expected, actual, "%#v", actual)
})
}
}

3
go.mod
View file

@ -4,7 +4,10 @@ go 1.18
require ( require (
github.com/google/uuid v1.1.2 github.com/google/uuid v1.1.2
github.com/gorilla/websocket v1.4.2
github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/go-homedir v1.1.0
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
github.com/rs/cors v1.8.2
github.com/spf13/cobra v1.1.1 github.com/spf13/cobra v1.1.1
github.com/spf13/viper v1.7.1 github.com/spf13/viper v1.7.1
github.com/stretchr/testify v1.4.0 github.com/stretchr/testify v1.4.0

6
go.sum
View file

@ -70,6 +70,7 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
@ -135,6 +136,8 @@ github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@ -151,6 +154,8 @@ github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7z
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rs/cors v1.8.2 h1:KCooALfAYGs415Cwu5ABvv9n9509fSiG5SQJn/AQo4U=
github.com/rs/cors v1.8.2/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
@ -250,6 +255,7 @@ golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 h1:xHms4gcpe1YE7A3yIllJXP16CMAGuqwO2lX1mTyyRRc= golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 h1:xHms4gcpe1YE7A3yIllJXP16CMAGuqwO2lX1mTyyRRc=
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=