Skip to content
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
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,64 @@ $ builder-playground cook l1 --latest-fork --output ~/my-builder-testnet --genes

To stop the playground, press `Ctrl+C`.

## Network Readiness

The playground can expose a `/readyz` HTTP endpoint to check if the network is ready to accept transactions (i.e., blocks are being produced).

### Readyz Endpoint

Enable the readyz server with the `--readyz-port` flag:

```bash
$ builder-playground cook l1 --readyz-port 8080
```

Then check readiness:

```bash
$ curl http://localhost:8080/readyz
{"ready":true}
```

Returns:
- `200 OK` with `{"ready": true}` when the network is producing blocks
- `503 Service Unavailable` with `{"ready": false, "error": "..."}` otherwise

### Wait-Ready Command

Use the `wait-ready` command to block until the network is ready:

```bash
$ builder-playground wait-ready [flags]
```

Flags:
- `--url` (string): readyz endpoint URL. Defaults to `http://localhost:8080/readyz`
- `--timeout` (duration): Maximum time to wait. Defaults to `60s`
- `--interval` (duration): Poll interval. Defaults to `1s`

Example:

```bash
# In terminal 1: Start the playground with readyz enabled
$ builder-playground cook l1 --readyz-port 8080

# In terminal 2: Wait for the network to be ready
$ builder-playground wait-ready --timeout 120s
Waiting for http://localhost:8080/readyz (timeout: 2m0s, interval: 1s)
[1s] Attempt 1: 503 Service Unavailable
[2s] Attempt 2: 503 Service Unavailable
[3s] Ready! (200 OK)
```

This is useful for CI/CD pipelines or scripts that need to wait for the network before deploying contracts.

Alternatively, use a bash one-liner:

```bash
$ timeout 60 bash -c 'until curl -sf http://localhost:8080/readyz | grep -q "\"ready\":true"; do sleep 1; done'
```

## Inspect

Builder-playground supports inspecting the connection of a service to a specific port.
Expand Down
21 changes: 20 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"time"

"github.com/flashbots/builder-playground/playground"
"github.com/flashbots/builder-playground/playground/cmd"
"github.com/spf13/cobra"
)

Expand All @@ -33,6 +34,7 @@ var platform string
var contenderEnabled bool
var contenderArgs []string
var contenderTarget string
var readyzPort int

var rootCmd = &cobra.Command{
Use: "playground",
Expand Down Expand Up @@ -185,6 +187,7 @@ func main() {
recipeCmd.Flags().BoolVar(&contenderEnabled, "contender", false, "spam nodes with contender")
recipeCmd.Flags().StringArrayVar(&contenderArgs, "contender.arg", []string{}, "add/override contender CLI flags")
recipeCmd.Flags().StringVar(&contenderTarget, "contender.target", "", "override the node that contender spams -- accepts names like \"el\"")
recipeCmd.Flags().IntVar(&readyzPort, "readyz-port", 0, "port for readyz HTTP endpoint (0 to disable)")

cookCmd.AddCommand(recipeCmd)
}
Expand All @@ -193,10 +196,13 @@ func main() {
artifactsCmd.Flags().StringVar(&outputFlag, "output", "", "Output folder for the artifacts")
artifactsAllCmd.Flags().StringVar(&outputFlag, "output", "", "Output folder for the artifacts")

cmd.InitWaitReadyCmd()

rootCmd.AddCommand(cookCmd)
rootCmd.AddCommand(artifactsCmd)
rootCmd.AddCommand(artifactsAllCmd)
rootCmd.AddCommand(inspectCmd)
rootCmd.AddCommand(cmd.WaitReadyCmd)

if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
Expand Down Expand Up @@ -296,6 +302,16 @@ func runIt(recipe playground.Recipe) error {
cancel()
}()

var readyzServer *playground.ReadyzServer
if readyzPort > 0 {
readyzServer = playground.NewReadyzServer(dockerRunner.Instances(), readyzPort)
if err := readyzServer.Start(); err != nil {
return fmt.Errorf("failed to start readyz server: %w", err)
}
defer readyzServer.Stop()
fmt.Printf("Readyz endpoint available at http://localhost:%d/readyz\n", readyzPort)
}

if err := dockerRunner.Run(); err != nil {
dockerRunner.Stop()
return fmt.Errorf("failed to run docker: %w", err)
Expand Down Expand Up @@ -327,10 +343,13 @@ func runIt(recipe playground.Recipe) error {
return fmt.Errorf("failed to wait for service readiness: %w", err)
}

fmt.Printf("\nWaiting for network to be ready for transactions...\n")
networkReadyStart := time.Now()
if err := playground.CompleteReady(dockerRunner.Instances()); err != nil {
dockerRunner.Stop()
return fmt.Errorf("failed to complete ready: %w", err)
return fmt.Errorf("network not ready: %w", err)
}
fmt.Printf("Network is ready for transactions (took %.1fs)\n", time.Since(networkReadyStart).Seconds())

// get the output from the recipe
output := recipe.Output(svcManager)
Expand Down
93 changes: 93 additions & 0 deletions playground/cmd/wait_ready.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package cmd

import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/signal"
"time"

"github.com/flashbots/builder-playground/playground"
"github.com/spf13/cobra"
)

var waitReadyURL string
var waitReadyTimeout time.Duration
var waitReadyInterval time.Duration

var WaitReadyCmd = &cobra.Command{
Use: "wait-ready",
Short: "Wait for the network to be ready for transactions",
RunE: func(cmd *cobra.Command, args []string) error {
return waitForReady()
},
}

func InitWaitReadyCmd() {
WaitReadyCmd.Flags().StringVar(&waitReadyURL, "url", "http://localhost:8080/readyz", "readyz endpoint URL")
WaitReadyCmd.Flags().DurationVar(&waitReadyTimeout, "timeout", 60*time.Second, "maximum time to wait")
WaitReadyCmd.Flags().DurationVar(&waitReadyInterval, "interval", 1*time.Second, "poll interval")
}

func waitForReady() error {
fmt.Printf("Waiting for %s (timeout: %s, interval: %s)\n", waitReadyURL, waitReadyTimeout, waitReadyInterval)

sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt)

ctx, cancel := context.WithCancel(context.Background())
go func() {
<-sig
cancel()
}()

client := &http.Client{
Timeout: 5 * time.Second,
}

deadline := time.Now().Add(waitReadyTimeout)
attempt := 0

for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return fmt.Errorf("interrupted")
default:
}

attempt++
elapsed := time.Since(deadline.Add(-waitReadyTimeout))

resp, err := client.Get(waitReadyURL)
if err != nil {
fmt.Printf(" [%s] Attempt %d: connection error: %v\n", elapsed.Truncate(time.Second), attempt, err)
time.Sleep(waitReadyInterval)
continue
}

var readyzResp playground.ReadyzResponse
if err := json.NewDecoder(resp.Body).Decode(&readyzResp); err != nil {
resp.Body.Close()
fmt.Printf(" [%s] Attempt %d: failed to parse response: %v\n", elapsed.Truncate(time.Second), attempt, err)
time.Sleep(waitReadyInterval)
continue
}
resp.Body.Close()

if resp.StatusCode == http.StatusOK && readyzResp.Ready {
fmt.Printf(" [%s] Ready! (200 OK)\n", elapsed.Truncate(time.Second))
return nil
}

errMsg := ""
if readyzResp.Error != "" {
errMsg = fmt.Sprintf(" - %s", readyzResp.Error)
}
fmt.Printf(" [%s] Attempt %d: %d %s%s\n", elapsed.Truncate(time.Second), attempt, resp.StatusCode, http.StatusText(resp.StatusCode), errMsg)
time.Sleep(waitReadyInterval)
}

return fmt.Errorf("timeout waiting for readyz after %s", waitReadyTimeout)
}
12 changes: 12 additions & 0 deletions playground/components.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,13 @@ func (o *OpGeth) Name() string {
return "op-geth"
}

func (o *OpGeth) Ready(instance *instance) error {
opGethURL := fmt.Sprintf("http://localhost:%d", instance.service.MustGetPort("http").HostPort)
return waitForFirstBlock(context.Background(), opGethURL, 60*time.Second)
}

var _ ServiceWatchdog = &OpGeth{}
var _ ServiceReady = &OpGeth{}

func (o *OpGeth) Watchdog(out io.Writer, instance *instance, ctx context.Context) error {
gethURL := fmt.Sprintf("http://localhost:%d", instance.service.MustGetPort("http").HostPort)
Expand Down Expand Up @@ -477,7 +483,13 @@ func (r *RethEL) Name() string {
return "reth"
}

func (r *RethEL) Ready(instance *instance) error {
elURL := fmt.Sprintf("http://localhost:%d", instance.service.MustGetPort("http").HostPort)
return waitForFirstBlock(context.Background(), elURL, 60*time.Second)
}

var _ ServiceWatchdog = &RethEL{}
var _ ServiceReady = &RethEL{}

func (r *RethEL) Watchdog(out io.Writer, instance *instance, ctx context.Context) error {
rethURL := fmt.Sprintf("http://localhost:%d", instance.service.MustGetPort("http").HostPort)
Expand Down
3 changes: 2 additions & 1 deletion playground/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,8 @@ func ReadManifest(outputFolder string) (*Manifest, error) {
}

func (svcManager *Manifest) RunContenderIfEnabled() {
if svcManager.ctx.Contender.Enabled {

if svcManager.ctx.Contender != nil && svcManager.ctx.Contender.Enabled {
svcManager.AddService("contender", svcManager.ctx.Contender.Contender())
}
}
4 changes: 2 additions & 2 deletions playground/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func TestNodeRefString(t *testing.T) {
service: "test",
port: 80,
user: "test",
expected: "test@test:test",
expected: "test@test:80",
},
{
protocol: "http",
Expand All @@ -40,7 +40,7 @@ func TestNodeRefString(t *testing.T) {
service: "test",
port: 80,
user: "test",
expected: "http://test@test:test",
expected: "http://test@test:80",
},
{
protocol: "enode",
Expand Down
109 changes: 109 additions & 0 deletions playground/readyz.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package playground

import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
)

type ReadyzServer struct {
instances []*instance
port int
server *http.Server
mu sync.RWMutex
}

type ReadyzResponse struct {
Ready bool `json:"ready"`
Error string `json:"error,omitempty"`
}

func NewReadyzServer(instances []*instance, port int) *ReadyzServer {
return &ReadyzServer{
instances: instances,
port: port,
}
}

func (s *ReadyzServer) Start() error {
mux := http.NewServeMux()
mux.HandleFunc("/livez", s.handleLivez)
mux.HandleFunc("/readyz", s.handleReadyz)

s.server = &http.Server{
Addr: fmt.Sprintf(":%d", s.port),
Handler: mux,
}

go func() {
if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Printf("Readyz server error: %v\n", err)
}
}()

return nil
}

func (s *ReadyzServer) Stop() error {
if s.server != nil {
return s.server.Shutdown(context.Background())
}
return nil
}

func (s *ReadyzServer) handleLivez(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK")) //nolint:errcheck
}

func (s *ReadyzServer) handleReadyz(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}

ready, err := s.isReady()

response := ReadyzResponse{
Ready: ready,
}

if err != nil {
response.Error = err.Error()
}

w.Header().Set("Content-Type", "application/json")

if ready {
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(http.StatusServiceUnavailable)
}

if err := json.NewEncoder(w).Encode(response); err != nil {
fmt.Printf("Failed to encode readyz response: %v\n", err)
}
}

func (s *ReadyzServer) isReady() (bool, error) {
ctx := context.Background()
for _, inst := range s.instances {
if _, ok := inst.component.(ServiceReady); ok {
elURL := fmt.Sprintf("http://localhost:%d", inst.service.MustGetPort("http").HostPort)
ready, err := isChainProducingBlocks(ctx, elURL)
if err != nil {
return false, err
}
if !ready {
return false, nil
}
}
}
return true, nil
}

func (s *ReadyzServer) Port() int {
return s.port
}
Loading
Loading