Skip to content

Commit 66b7dac

Browse files
authored
feat: add network readiness (#210)
### Add Network Readiness Checking **Problem** After all services start and Docker healthchecks pass, the network may not yet be producing blocks. This makes it inconvenient for users who need to wait before deploying contracts or running tests, as they have to manually check when the network is actually ready for transactions. **Solution** Implemented network-level readiness checking that ensures the blockchain is producing blocks, not just that services are responsive. Closes #204
1 parent a9cc2c2 commit 66b7dac

File tree

8 files changed

+350
-4
lines changed

8 files changed

+350
-4
lines changed

README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,64 @@ $ builder-playground cook l1 --latest-fork --output ~/my-builder-testnet --genes
7575

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

78+
## Network Readiness
79+
80+
The playground can expose a `/readyz` HTTP endpoint to check if the network is ready to accept transactions (i.e., blocks are being produced).
81+
82+
### Readyz Endpoint
83+
84+
Enable the readyz server with the `--readyz-port` flag:
85+
86+
```bash
87+
$ builder-playground cook l1 --readyz-port 8080
88+
```
89+
90+
Then check readiness:
91+
92+
```bash
93+
$ curl http://localhost:8080/readyz
94+
{"ready":true}
95+
```
96+
97+
Returns:
98+
- `200 OK` with `{"ready": true}` when the network is producing blocks
99+
- `503 Service Unavailable` with `{"ready": false, "error": "..."}` otherwise
100+
101+
### Wait-Ready Command
102+
103+
Use the `wait-ready` command to block until the network is ready:
104+
105+
```bash
106+
$ builder-playground wait-ready [flags]
107+
```
108+
109+
Flags:
110+
- `--url` (string): readyz endpoint URL. Defaults to `http://localhost:8080/readyz`
111+
- `--timeout` (duration): Maximum time to wait. Defaults to `60s`
112+
- `--interval` (duration): Poll interval. Defaults to `1s`
113+
114+
Example:
115+
116+
```bash
117+
# In terminal 1: Start the playground with readyz enabled
118+
$ builder-playground cook l1 --readyz-port 8080
119+
120+
# In terminal 2: Wait for the network to be ready
121+
$ builder-playground wait-ready --timeout 120s
122+
Waiting for http://localhost:8080/readyz (timeout: 2m0s, interval: 1s)
123+
[1s] Attempt 1: 503 Service Unavailable
124+
[2s] Attempt 2: 503 Service Unavailable
125+
[3s] Ready! (200 OK)
126+
```
127+
128+
This is useful for CI/CD pipelines or scripts that need to wait for the network before deploying contracts.
129+
130+
Alternatively, use a bash one-liner:
131+
132+
```bash
133+
$ timeout 60 bash -c 'until curl -sf http://localhost:8080/readyz | grep -q "\"ready\":true"; do sleep 1; done'
134+
```
135+
78136
## Inspect
79137

80138
Builder-playground supports inspecting the connection of a service to a specific port.

main.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"time"
1414

1515
"github.com/flashbots/builder-playground/playground"
16+
"github.com/flashbots/builder-playground/playground/cmd"
1617
"github.com/spf13/cobra"
1718
)
1819

@@ -33,6 +34,7 @@ var platform string
3334
var contenderEnabled bool
3435
var contenderArgs []string
3536
var contenderTarget string
37+
var readyzPort int
3638

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

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

199+
cmd.InitWaitReadyCmd()
200+
196201
rootCmd.AddCommand(cookCmd)
197202
rootCmd.AddCommand(artifactsCmd)
198203
rootCmd.AddCommand(artifactsAllCmd)
199204
rootCmd.AddCommand(inspectCmd)
205+
rootCmd.AddCommand(cmd.WaitReadyCmd)
200206

201207
if err := rootCmd.Execute(); err != nil {
202208
fmt.Println(err)
@@ -296,6 +302,16 @@ func runIt(recipe playground.Recipe) error {
296302
cancel()
297303
}()
298304

305+
var readyzServer *playground.ReadyzServer
306+
if readyzPort > 0 {
307+
readyzServer = playground.NewReadyzServer(dockerRunner.Instances(), readyzPort)
308+
if err := readyzServer.Start(); err != nil {
309+
return fmt.Errorf("failed to start readyz server: %w", err)
310+
}
311+
defer readyzServer.Stop()
312+
fmt.Printf("Readyz endpoint available at http://localhost:%d/readyz\n", readyzPort)
313+
}
314+
299315
if err := dockerRunner.Run(); err != nil {
300316
dockerRunner.Stop()
301317
return fmt.Errorf("failed to run docker: %w", err)
@@ -327,10 +343,13 @@ func runIt(recipe playground.Recipe) error {
327343
return fmt.Errorf("failed to wait for service readiness: %w", err)
328344
}
329345

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

335354
// get the output from the recipe
336355
output := recipe.Output(svcManager)

playground/cmd/wait_ready.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"os"
9+
"os/signal"
10+
"time"
11+
12+
"github.com/flashbots/builder-playground/playground"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
var waitReadyURL string
17+
var waitReadyTimeout time.Duration
18+
var waitReadyInterval time.Duration
19+
20+
var WaitReadyCmd = &cobra.Command{
21+
Use: "wait-ready",
22+
Short: "Wait for the network to be ready for transactions",
23+
RunE: func(cmd *cobra.Command, args []string) error {
24+
return waitForReady()
25+
},
26+
}
27+
28+
func InitWaitReadyCmd() {
29+
WaitReadyCmd.Flags().StringVar(&waitReadyURL, "url", "http://localhost:8080/readyz", "readyz endpoint URL")
30+
WaitReadyCmd.Flags().DurationVar(&waitReadyTimeout, "timeout", 60*time.Second, "maximum time to wait")
31+
WaitReadyCmd.Flags().DurationVar(&waitReadyInterval, "interval", 1*time.Second, "poll interval")
32+
}
33+
34+
func waitForReady() error {
35+
fmt.Printf("Waiting for %s (timeout: %s, interval: %s)\n", waitReadyURL, waitReadyTimeout, waitReadyInterval)
36+
37+
sig := make(chan os.Signal, 1)
38+
signal.Notify(sig, os.Interrupt)
39+
40+
ctx, cancel := context.WithCancel(context.Background())
41+
go func() {
42+
<-sig
43+
cancel()
44+
}()
45+
46+
client := &http.Client{
47+
Timeout: 5 * time.Second,
48+
}
49+
50+
deadline := time.Now().Add(waitReadyTimeout)
51+
attempt := 0
52+
53+
for time.Now().Before(deadline) {
54+
select {
55+
case <-ctx.Done():
56+
return fmt.Errorf("interrupted")
57+
default:
58+
}
59+
60+
attempt++
61+
elapsed := time.Since(deadline.Add(-waitReadyTimeout))
62+
63+
resp, err := client.Get(waitReadyURL)
64+
if err != nil {
65+
fmt.Printf(" [%s] Attempt %d: connection error: %v\n", elapsed.Truncate(time.Second), attempt, err)
66+
time.Sleep(waitReadyInterval)
67+
continue
68+
}
69+
70+
var readyzResp playground.ReadyzResponse
71+
if err := json.NewDecoder(resp.Body).Decode(&readyzResp); err != nil {
72+
resp.Body.Close()
73+
fmt.Printf(" [%s] Attempt %d: failed to parse response: %v\n", elapsed.Truncate(time.Second), attempt, err)
74+
time.Sleep(waitReadyInterval)
75+
continue
76+
}
77+
resp.Body.Close()
78+
79+
if resp.StatusCode == http.StatusOK && readyzResp.Ready {
80+
fmt.Printf(" [%s] Ready! (200 OK)\n", elapsed.Truncate(time.Second))
81+
return nil
82+
}
83+
84+
errMsg := ""
85+
if readyzResp.Error != "" {
86+
errMsg = fmt.Sprintf(" - %s", readyzResp.Error)
87+
}
88+
fmt.Printf(" [%s] Attempt %d: %d %s%s\n", elapsed.Truncate(time.Second), attempt, resp.StatusCode, http.StatusText(resp.StatusCode), errMsg)
89+
time.Sleep(waitReadyInterval)
90+
}
91+
92+
return fmt.Errorf("timeout waiting for readyz after %s", waitReadyTimeout)
93+
}

playground/components.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,13 @@ func (o *OpGeth) Name() string {
382382
return "op-geth"
383383
}
384384

385+
func (o *OpGeth) Ready(instance *instance) error {
386+
opGethURL := fmt.Sprintf("http://localhost:%d", instance.service.MustGetPort("http").HostPort)
387+
return waitForFirstBlock(context.Background(), opGethURL, 60*time.Second)
388+
}
389+
385390
var _ ServiceWatchdog = &OpGeth{}
391+
var _ ServiceReady = &OpGeth{}
386392

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

486+
func (r *RethEL) Ready(instance *instance) error {
487+
elURL := fmt.Sprintf("http://localhost:%d", instance.service.MustGetPort("http").HostPort)
488+
return waitForFirstBlock(context.Background(), elURL, 60*time.Second)
489+
}
490+
480491
var _ ServiceWatchdog = &RethEL{}
492+
var _ ServiceReady = &RethEL{}
481493

482494
func (r *RethEL) Watchdog(out io.Writer, instance *instance, ctx context.Context) error {
483495
rethURL := fmt.Sprintf("http://localhost:%d", instance.service.MustGetPort("http").HostPort)

playground/manifest.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -608,7 +608,8 @@ func ReadManifest(outputFolder string) (*Manifest, error) {
608608
}
609609

610610
func (svcManager *Manifest) RunContenderIfEnabled() {
611-
if svcManager.ctx.Contender.Enabled {
611+
612+
if svcManager.ctx.Contender != nil && svcManager.ctx.Contender.Enabled {
612613
svcManager.AddService("contender", svcManager.ctx.Contender.Contender())
613614
}
614615
}

playground/manifest_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ func TestNodeRefString(t *testing.T) {
2626
service: "test",
2727
port: 80,
2828
user: "test",
29-
expected: "test@test:test",
29+
expected: "test@test:80",
3030
},
3131
{
3232
protocol: "http",
@@ -40,7 +40,7 @@ func TestNodeRefString(t *testing.T) {
4040
service: "test",
4141
port: 80,
4242
user: "test",
43-
expected: "http://test@test:test",
43+
expected: "http://test@test:80",
4444
},
4545
{
4646
protocol: "enode",

playground/readyz.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package playground
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"sync"
9+
)
10+
11+
type ReadyzServer struct {
12+
instances []*instance
13+
port int
14+
server *http.Server
15+
mu sync.RWMutex
16+
}
17+
18+
type ReadyzResponse struct {
19+
Ready bool `json:"ready"`
20+
Error string `json:"error,omitempty"`
21+
}
22+
23+
func NewReadyzServer(instances []*instance, port int) *ReadyzServer {
24+
return &ReadyzServer{
25+
instances: instances,
26+
port: port,
27+
}
28+
}
29+
30+
func (s *ReadyzServer) Start() error {
31+
mux := http.NewServeMux()
32+
mux.HandleFunc("/livez", s.handleLivez)
33+
mux.HandleFunc("/readyz", s.handleReadyz)
34+
35+
s.server = &http.Server{
36+
Addr: fmt.Sprintf(":%d", s.port),
37+
Handler: mux,
38+
}
39+
40+
go func() {
41+
if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
42+
fmt.Printf("Readyz server error: %v\n", err)
43+
}
44+
}()
45+
46+
return nil
47+
}
48+
49+
func (s *ReadyzServer) Stop() error {
50+
if s.server != nil {
51+
return s.server.Shutdown(context.Background())
52+
}
53+
return nil
54+
}
55+
56+
func (s *ReadyzServer) handleLivez(w http.ResponseWriter, r *http.Request) {
57+
w.WriteHeader(http.StatusOK)
58+
w.Write([]byte("OK")) //nolint:errcheck
59+
}
60+
61+
func (s *ReadyzServer) handleReadyz(w http.ResponseWriter, r *http.Request) {
62+
if r.Method != http.MethodGet {
63+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
64+
return
65+
}
66+
67+
ready, err := s.isReady()
68+
69+
response := ReadyzResponse{
70+
Ready: ready,
71+
}
72+
73+
if err != nil {
74+
response.Error = err.Error()
75+
}
76+
77+
w.Header().Set("Content-Type", "application/json")
78+
79+
if ready {
80+
w.WriteHeader(http.StatusOK)
81+
} else {
82+
w.WriteHeader(http.StatusServiceUnavailable)
83+
}
84+
85+
if err := json.NewEncoder(w).Encode(response); err != nil {
86+
fmt.Printf("Failed to encode readyz response: %v\n", err)
87+
}
88+
}
89+
90+
func (s *ReadyzServer) isReady() (bool, error) {
91+
ctx := context.Background()
92+
for _, inst := range s.instances {
93+
if _, ok := inst.component.(ServiceReady); ok {
94+
elURL := fmt.Sprintf("http://localhost:%d", inst.service.MustGetPort("http").HostPort)
95+
ready, err := isChainProducingBlocks(ctx, elURL)
96+
if err != nil {
97+
return false, err
98+
}
99+
if !ready {
100+
return false, nil
101+
}
102+
}
103+
}
104+
return true, nil
105+
}
106+
107+
func (s *ReadyzServer) Port() int {
108+
return s.port
109+
}

0 commit comments

Comments
 (0)