diff --git a/README.md b/README.md index aa3ed97..ce351c3 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ $ builder-playground cook l1 --latest-fork --output ~/my-builder-testnet --genes ## Common Options - `--output` (string): The directory where the chain data and artifacts are stored. Defaults to `$HOME/.playground/devnet` +- `--detached` (bool): Run the recipes in the background. Defaults to `false`. - `--genesis-delay` (int): The delay in seconds before the genesis block is created. Defaults to `10` seconds - `--watchdog` (bool): Enable the watchdog service to monitor the specific chain - `--dry-run` (bool): Generates the artifacts and manifest but does not deploy anything (also enabled with the `--mise-en-place` flag) @@ -150,6 +151,14 @@ $ builder-playground inspect op-geth authrpc This command starts a `tcpflow` container in the same network interface as the service and captures the traffic to the specified port. +## Clean + +Removes a recipe running in the background + +```bash +$ builder-playground clean [--output ./output] +``` + ## Internals ### Execution Flow diff --git a/go.mod b/go.mod index ca11e66..2c3834b 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/spf13/pflag v1.0.6 github.com/stretchr/testify v1.10.0 github.com/wealdtech/go-eth2-wallet-encryptor-keystorev4 v1.1.3 + golang.org/x/sync v0.13.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -152,7 +153,6 @@ require ( golang.org/x/crypto v0.37.0 // indirect golang.org/x/net v0.38.0 // indirect golang.org/x/oauth2 v0.26.0 // indirect - golang.org/x/sync v0.13.0 // indirect golang.org/x/sys v0.32.0 // indirect golang.org/x/term v0.31.0 // indirect golang.org/x/text v0.24.0 // indirect diff --git a/main.go b/main.go index 5a8d0c8..c9a9cb8 100644 --- a/main.go +++ b/main.go @@ -13,7 +13,7 @@ import ( "time" "github.com/flashbots/builder-playground/playground" - "github.com/flashbots/builder-playground/playground/cmd" + "github.com/google/uuid" "github.com/spf13/cobra" ) @@ -34,7 +34,7 @@ var platform string var contenderEnabled bool var contenderArgs []string var contenderTarget string -var readyzPort int +var detached bool var rootCmd = &cobra.Command{ Use: "playground", @@ -57,6 +57,22 @@ var cookCmd = &cobra.Command{ }, } +var cleanCmd = &cobra.Command{ + Use: "clean", + Short: "Clean a recipe", + RunE: func(cmd *cobra.Command, args []string) error { + manifest, err := playground.ReadManifest(outputFlag) + if err != nil { + return err + } + if err := playground.StopContainersBySessionID(manifest.ID); err != nil { + return err + } + fmt.Println("The recipe has been stopped and cleaned.") + return nil + }, +} + var inspectCmd = &cobra.Command{ Use: "inspect", Short: "Inspect a connection between two services", @@ -120,16 +136,16 @@ 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)") + recipeCmd.Flags().BoolVar(&detached, "detached", false, "Detached mode: Run the recipes in the background") cookCmd.AddCommand(recipeCmd) } - cmd.InitWaitReadyCmd() - rootCmd.AddCommand(cookCmd) rootCmd.AddCommand(inspectCmd) - rootCmd.AddCommand(cmd.WaitReadyCmd) + + rootCmd.AddCommand(cleanCmd) + cleanCmd.Flags().StringVar(&outputFlag, "output", "", "Output folder for the artifacts") if err := rootCmd.Execute(); err != nil { fmt.Println(err) @@ -172,7 +188,10 @@ func runIt(recipe playground.Recipe) error { TargetChain: contenderTarget, }, } + svcManager := playground.NewManifest(exCtx, artifacts.Out) + svcManager.ID = uuid.New().String() + recipe.Apply(svcManager) if err := svcManager.Validate(); err != nil { return fmt.Errorf("failed to validate manifest: %w", err) @@ -184,6 +203,10 @@ func runIt(recipe playground.Recipe) error { return err } + if err := svcManager.Validate(); err != nil { + return fmt.Errorf("failed to validate manifest: %w", err) + } + // save the manifest.json file if err := svcManager.SaveJson(); err != nil { return fmt.Errorf("failed to save manifest: %w", err) @@ -231,16 +254,6 @@ 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) @@ -289,6 +302,10 @@ func runIt(recipe playground.Recipe) error { } } + if detached { + return nil + } + watchdogErr := make(chan error, 1) if watchdog { go func() { diff --git a/playground/cmd/wait_ready.go b/playground/cmd/wait_ready.go deleted file mode 100644 index 6ff1a7b..0000000 --- a/playground/cmd/wait_ready.go +++ /dev/null @@ -1,93 +0,0 @@ -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/local_runner.go b/playground/local_runner.go index 37e2031..13ce7b1 100644 --- a/playground/local_runner.go +++ b/playground/local_runner.go @@ -23,7 +23,7 @@ import ( "github.com/docker/docker/client" "github.com/docker/docker/pkg/stdcopy" "github.com/ethereum/go-ethereum/log" - "github.com/google/uuid" + "golang.org/x/sync/errgroup" "gopkg.in/yaml.v2" ) @@ -69,10 +69,6 @@ type LocalRunner struct { // wether to bind the ports to the local interface bindHostPortsLocally bool - // sessionID is a random sequence that is used to identify the session - // it is used to identify the containers in the cleanup process - sessionID string - // networkName is the name of the network to use for the services networkName string @@ -220,7 +216,6 @@ func NewLocalRunner(cfg *RunnerConfig) (*LocalRunner, error) { taskUpdateCh: make(chan struct{}), exitErr: make(chan error, 2), bindHostPortsLocally: cfg.BindHostPortsLocally, - sessionID: uuid.New().String(), networkName: cfg.NetworkName, instances: instances, labels: cfg.Labels, @@ -378,7 +373,7 @@ func (d *LocalRunner) ExitErr() <-chan error { func (d *LocalRunner) Stop() error { // only stop the containers that belong to this session containers, err := d.client.ContainerList(context.Background(), container.ListOptions{ - Filters: filters.NewArgs(filters.Arg("label", fmt.Sprintf("playground.session=%s", d.sessionID))), + Filters: filters.NewArgs(filters.Arg("label", fmt.Sprintf("playground.session=%s", d.manifest.ID))), }) if err != nil { return fmt.Errorf("error getting container list: %w", err) @@ -615,7 +610,7 @@ func (d *LocalRunner) toDockerComposeService(s *Service) (map[string]interface{} // It is important to use the playground label to identify the containers // during the cleanup process "playground": "true", - "playground.session": d.sessionID, + "playground.session": d.manifest.ID, "service": s.Name, } @@ -883,7 +878,7 @@ func (d *LocalRunner) trackLogs(serviceName string, containerID string) error { func (d *LocalRunner) trackContainerStatusAndLogs() { eventCh, errCh := d.client.Events(context.Background(), events.ListOptions{ - Filters: filters.NewArgs(filters.Arg("label", fmt.Sprintf("playground.session=%s", d.sessionID))), + Filters: filters.NewArgs(filters.Arg("label", fmt.Sprintf("playground.session=%s", d.manifest.ID))), }) for { @@ -1030,3 +1025,40 @@ func (d *LocalRunner) Run() error { } return nil } + +// StopContainersBySessionID removes all Docker containers associated with a specific playground session ID. +// This is a standalone utility function used by the clean command to stop containers without requiring +// a LocalRunner instance or manifest reference. +// +// TODO: Refactor to reduce code duplication with LocalRunner.Stop() +// Consider creating a shared dockerClient wrapper with helper methods for container management +// that both LocalRunner and this function can use. +func StopContainersBySessionID(id string) error { + client, err := newDockerClient() + if err != nil { + return err + } + + containers, err := client.ContainerList(context.Background(), container.ListOptions{ + Filters: filters.NewArgs(filters.Arg("label", fmt.Sprintf("playground.session=%s", id))), + }) + if err != nil { + return fmt.Errorf("error getting container list: %w", err) + } + + g := new(errgroup.Group) + for _, cont := range containers { + g.Go(func() error { + if err := client.ContainerRemove(context.Background(), cont.ID, container.RemoveOptions{ + RemoveVolumes: true, + RemoveLinks: false, + Force: true, + }); err != nil { + return fmt.Errorf("error removing container: %w", err) + } + return nil + }) + } + + return g.Wait() +} diff --git a/playground/manifest.go b/playground/manifest.go index bee4187..f86eac0 100644 --- a/playground/manifest.go +++ b/playground/manifest.go @@ -28,6 +28,7 @@ type Recipe interface { // Manifest describes a list of services and their dependencies type Manifest struct { ctx *ExContext + ID string `json:"session_id"` // list of Services Services []*Service `json:"services"` diff --git a/playground/readyz.go b/playground/readyz.go deleted file mode 100644 index 277512d..0000000 --- a/playground/readyz.go +++ /dev/null @@ -1,109 +0,0 @@ -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 inst.service.readyFn != nil { - 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 b7347a8..56bce92 100644 --- a/playground/watchers.go +++ b/playground/watchers.go @@ -14,21 +14,6 @@ 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 {