diff --git a/README.md b/README.md index 5331b30..aa3ed97 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/main.go b/main.go index 1ddeffb..f1a1e30 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "time" "github.com/flashbots/builder-playground/playground" + "github.com/flashbots/builder-playground/playground/cmd" "github.com/spf13/cobra" ) @@ -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", @@ -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) } @@ -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) @@ -296,6 +302,16 @@ func runIt(recipe playground.Recipe) error { cancel() }() + var readyzServer *playground.ReadyzServer + if checker, ok := recipe.(playground.NetworkReadyChecker); ok && readyzPort > 0 { + readyzServer = playground.NewReadyzServer(checker, svcManager, 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) @@ -332,6 +348,16 @@ func runIt(recipe playground.Recipe) error { return fmt.Errorf("failed to complete ready: %w", err) } + if checker, ok := recipe.(playground.NetworkReadyChecker); ok { + fmt.Printf("\nWaiting for network to be ready for transactions...\n") + networkReadyStart := time.Now() + if err := checker.WaitForNetworkReady(ctx, svcManager, 60*time.Second); err != nil { + dockerRunner.Stop() + 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) if len(output) > 0 { diff --git a/playground/cmd/wait_ready.go b/playground/cmd/wait_ready.go new file mode 100644 index 0000000..6ff1a7b --- /dev/null +++ b/playground/cmd/wait_ready.go @@ -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) +} diff --git a/playground/manifest.go b/playground/manifest.go index e06b9c9..c5e15f0 100644 --- a/playground/manifest.go +++ b/playground/manifest.go @@ -1,6 +1,7 @@ package playground import ( + "context" "encoding/json" "fmt" "os" @@ -23,6 +24,18 @@ type Recipe interface { Output(manifest *Manifest) map[string]interface{} } +// NetworkReadyChecker is an optional interface that recipes can implement +// to provide network-level readiness checks beyond individual service health. +// This ensures the network is actually producing blocks and can accept transactions. +type NetworkReadyChecker interface { + // IsNetworkReady returns true if the network is ready to accept transactions. + // This can be used to implement /readyz endpoints. + IsNetworkReady(ctx context.Context, manifest *Manifest) (bool, error) + + // WaitForNetworkReady blocks until the network is ready or context is cancelled/timeout. + WaitForNetworkReady(ctx context.Context, manifest *Manifest, timeout time.Duration) error +} + // Manifest describes a list of services and their dependencies type Manifest struct { ctx *ExContext diff --git a/playground/readyz.go b/playground/readyz.go new file mode 100644 index 0000000..abc2f7a --- /dev/null +++ b/playground/readyz.go @@ -0,0 +1,87 @@ +package playground + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sync" +) + +type ReadyzServer struct { + checker NetworkReadyChecker + manifest *Manifest + port int + server *http.Server + mu sync.RWMutex +} + +type ReadyzResponse struct { + Ready bool `json:"ready"` + Error string `json:"error,omitempty"` +} + +func NewReadyzServer(checker NetworkReadyChecker, manifest *Manifest, port int) *ReadyzServer { + return &ReadyzServer{ + checker: checker, + manifest: manifest, + port: port, + } +} + +func (s *ReadyzServer) Start() error { + mux := http.NewServeMux() + 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) handleReadyz(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + ctx := r.Context() + ready, err := s.checker.IsNetworkReady(ctx, s.manifest) + + 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) + } + + json.NewEncoder(w).Encode(response) +} + +func (s *ReadyzServer) Port() int { + return s.port +} diff --git a/playground/recipe_buildernet.go b/playground/recipe_buildernet.go index d205095..2a61388 100644 --- a/playground/recipe_buildernet.go +++ b/playground/recipe_buildernet.go @@ -1,12 +1,15 @@ package playground import ( + "context" "fmt" + "time" flag "github.com/spf13/pflag" ) var _ Recipe = &BuilderNetRecipe{} +var _ NetworkReadyChecker = &BuilderNetRecipe{} // BuilderNetRecipe is a recipe that extends the L1 recipe to include builder-hub type BuilderNetRecipe struct { @@ -90,3 +93,11 @@ func (b *BuilderNetRecipe) Output(manifest *Manifest) map[string]interface{} { return output } + +func (b *BuilderNetRecipe) IsNetworkReady(ctx context.Context, manifest *Manifest) (bool, error) { + return b.l1Recipe.IsNetworkReady(ctx, manifest) +} + +func (b *BuilderNetRecipe) WaitForNetworkReady(ctx context.Context, manifest *Manifest, timeout time.Duration) error { + return b.l1Recipe.WaitForNetworkReady(ctx, manifest, timeout) +} diff --git a/playground/recipe_l1.go b/playground/recipe_l1.go index b45c2b0..733433b 100644 --- a/playground/recipe_l1.go +++ b/playground/recipe_l1.go @@ -1,12 +1,15 @@ package playground import ( + "context" "fmt" + "time" flag "github.com/spf13/pflag" ) var _ Recipe = &L1Recipe{} +var _ NetworkReadyChecker = &L1Recipe{} type L1Recipe struct { // latestFork enables the use of the latest fork at startup @@ -115,3 +118,15 @@ func (l *L1Recipe) Apply(ctx *ExContext, artifacts *Artifacts) *Manifest { func (l *L1Recipe) Output(manifest *Manifest) map[string]interface{} { return map[string]interface{}{} } + +func (l *L1Recipe) IsNetworkReady(ctx context.Context, manifest *Manifest) (bool, error) { + elService := manifest.MustGetService("el") + elURL := fmt.Sprintf("http://localhost:%d", elService.MustGetPort("http").HostPort) + return isChainProducingBlocks(ctx, elURL) +} + +func (l *L1Recipe) WaitForNetworkReady(ctx context.Context, manifest *Manifest, timeout time.Duration) error { + elService := manifest.MustGetService("el") + elURL := fmt.Sprintf("http://localhost:%d", elService.MustGetPort("http").HostPort) + return waitForFirstBlock(ctx, elURL, timeout) +} diff --git a/playground/recipe_opstack.go b/playground/recipe_opstack.go index d0b1a0e..ad821b2 100644 --- a/playground/recipe_opstack.go +++ b/playground/recipe_opstack.go @@ -1,10 +1,15 @@ package playground import ( + "context" + "fmt" + "time" + flag "github.com/spf13/pflag" ) var _ Recipe = &OpRecipe{} +var _ NetworkReadyChecker = &OpRecipe{} // OpRecipe is a recipe that deploys an OP stack type OpRecipe struct { @@ -195,3 +200,40 @@ func (o *OpRecipe) Output(manifest *Manifest) map[string]interface{} { */ return map[string]interface{}{} } + +func (o *OpRecipe) IsNetworkReady(ctx context.Context, manifest *Manifest) (bool, error) { + elService := manifest.MustGetService("el") + elURL := fmt.Sprintf("http://localhost:%d", elService.MustGetPort("http").HostPort) + l1Ready, err := isChainProducingBlocks(ctx, elURL) + if err != nil { + return false, fmt.Errorf("L1 check failed: %w", err) + } + if !l1Ready { + return false, nil + } + + opGethService := manifest.MustGetService("op-geth") + opGethURL := fmt.Sprintf("http://localhost:%d", opGethService.MustGetPort("http").HostPort) + l2Ready, err := isChainProducingBlocks(ctx, opGethURL) + if err != nil { + return false, fmt.Errorf("L2 check failed: %w", err) + } + + return l2Ready, nil +} + +func (o *OpRecipe) WaitForNetworkReady(ctx context.Context, manifest *Manifest, timeout time.Duration) error { + elService := manifest.MustGetService("el") + elURL := fmt.Sprintf("http://localhost:%d", elService.MustGetPort("http").HostPort) + if err := waitForFirstBlock(ctx, elURL, timeout); err != nil { + return fmt.Errorf("L1 not ready: %w", err) + } + + opGethService := manifest.MustGetService("op-geth") + opGethURL := fmt.Sprintf("http://localhost:%d", opGethService.MustGetPort("http").HostPort) + if err := waitForFirstBlock(ctx, opGethURL, timeout); err != nil { + return fmt.Errorf("L2 not ready: %w", err) + } + + return nil +} diff --git a/playground/watchers.go b/playground/watchers.go index a9a4870..f6871b9 100644 --- a/playground/watchers.go +++ b/playground/watchers.go @@ -14,6 +14,75 @@ import ( mevRCommon "github.com/flashbots/mev-boost-relay/common" ) +func isChainProducingBlocks(ctx context.Context, elURL string) (bool, error) { + rpcClient, err := rpc.Dial(elURL) + if err != nil { + return false, err + } + defer rpcClient.Close() + + clt := ethclient.NewClient(rpcClient) + num, err := clt.BlockNumber(ctx) + if err != nil { + return false, err + } + return num > 0, nil +} + +func isChainProducingBlocksWithLogging(ctx context.Context, elURL string) (bool, uint64, error) { + rpcClient, err := rpc.Dial(elURL) + if err != nil { + return false, 0, err + } + defer rpcClient.Close() + + clt := ethclient.NewClient(rpcClient) + num, err := clt.BlockNumber(ctx) + if err != nil { + return false, 0, err + } + return num > 0, num, nil +} + +func waitForFirstBlock(ctx context.Context, elURL string, timeout time.Duration) error { + rpcClient, err := rpc.Dial(elURL) + if err != nil { + fmt.Printf(" [%s] Failed to connect: %v\n", elURL, err) + return err + } + defer rpcClient.Close() + + clt := ethclient.NewClient(rpcClient) + fmt.Printf(" [%s] Connected, waiting for first block...\n", elURL) + + timeoutCh := time.After(timeout) + checkCount := 0 + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-timeoutCh: + return fmt.Errorf("timeout waiting for first block on %s", elURL) + case <-time.After(500 * time.Millisecond): + num, err := clt.BlockNumber(ctx) + checkCount++ + if err != nil { + if checkCount%10 == 0 { + fmt.Printf(" [%s] Error getting block number: %v\n", elURL, err) + } + continue + } + if num > 0 { + fmt.Printf(" [%s] First block detected: %d\n", elURL, num) + return nil + } + if checkCount%10 == 0 { + fmt.Printf(" [%s] Block number: %d (waiting for > 0)\n", elURL, num) + } + } + } +} + func waitForChainAlive(ctx context.Context, logOutput io.Writer, beaconNodeURL string, timeout time.Duration) error { // Test that blocks are being produced log := mevRCommon.LogSetup(false, "info").WithField("context", "waitForChainAlive")