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..1759f0b 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 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) @@ -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) 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/components.go b/playground/components.go index 2be2713..86a841d 100644 --- a/playground/components.go +++ b/playground/components.go @@ -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) @@ -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) diff --git a/playground/manifest.go b/playground/manifest.go index e06b9c9..15dea61 100644 --- a/playground/manifest.go +++ b/playground/manifest.go @@ -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()) } } diff --git a/playground/manifest_test.go b/playground/manifest_test.go index 8431679..57415b1 100644 --- a/playground/manifest_test.go +++ b/playground/manifest_test.go @@ -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", @@ -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", diff --git a/playground/readyz.go b/playground/readyz.go new file mode 100644 index 0000000..264a40d --- /dev/null +++ b/playground/readyz.go @@ -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 +} diff --git a/playground/watchers.go b/playground/watchers.go index a9a4870..b7347a8 100644 --- a/playground/watchers.go +++ b/playground/watchers.go @@ -14,6 +14,60 @@ 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 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")