diff --git a/.gitignore b/.gitignore index dd0390c..046ca06 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,6 @@ bin/ # Environment files .env .env.* - +.DS_Store # XML files (repomix output) *.xml diff --git a/Dockerfile b/Dockerfile index 354c136..3404b69 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.24.5-alpine AS builder +FROM golang:1.25-alpine AS builder WORKDIR /app diff --git a/README.md b/README.md index bbbcd72..66b3461 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,27 @@ This service acts as a dedicated bootstrap node for the PowerLoom decentralized sequencer network. Its primary purpose is to provide a stable, well-known entry point for other libp2p nodes (like snapshotters and validators) to discover and connect to the network. -By connecting to this bootstrap node, new peers can quickly find other participants in the network, facilitating efficient peer discovery and message propagation for Gossipsub topics. +By connecting to this bootstrap node, new peers obtain a stable dial target and DHT routing assistance to find other participants; gossipsub mesh formation happens directly between snapshotters and validators. ## Features - **Stable Entry Point:** Provides a consistent multiaddress for new nodes to join the network. - **Peer Discovery:** Helps other nodes discover more peers in the network via libp2p's DHT. -- **Lightweight:** Designed to be a simple, robust, and long-running service with minimal overhead. +- **Discovery-only:** Does **not** run gossipsub. Snapshotters and validators carry mesh traffic; the bootstrap node only accepts dial-ins and serves DHT routing. +- **Lightweight defaults:** Tight connection limits, bounded libp2p memory, no per-connection info logs unless opted in. + +## Resource model (why older builds used 2+ GiB / 300% CPU) + +A bootstrap node only needs TCP listen + Kademlia DHT. Prior versions also started **full gossipsub** (700ms heartbeats, peer scoring, flood publish) on every inbound peer, enabled **circuit relay** by default, allowed **500–2000** connections, and logged **every** connect/disconnect at info. That behaves like a mesh participant, not a rendezvous point — the `fix/memory-leak` peerstore GC did not change that. + +| Setting | Default (new) | Typical old prod `.env` | +|---|---|---| +| `CONN_MANAGER_HIGH_WATER` | `128` | `800` | +| Gossipsub | off | on (unused, no topic join) | +| `ENABLE_RELAY_SERVICE` | `true` (capped) | relay on, unbounded | +| `RELAY_MAX_RESERVATIONS` | `256` | — | +| `LOG_PEER_CONNECTIONS` | `false` | info log per peer | +| `RCMGR_MEMORY_LIMIT_MB` | `512` | unlimited | ## Build @@ -74,6 +88,15 @@ To ensure a consistent Peer ID and multiaddress for your bootstrap node, you sho ```dotenv PRIVATE_KEY=your_generated_private_key_here + PUBLIC_IP=your.vps.public.ip + # NAT snapshotters without PUBLIC_IP use bootstrap as AutoRelay static relay (default on) + # ENABLE_RELAY_SERVICE=true + # RELAY_MAX_RESERVATIONS=256 + # RELAY_MAX_RESERVATIONS_PER_IP=32 + # CONN_MANAGER_HIGH_WATER=256 + # RCMGR_MEMORY_LIMIT_MB=512 + # LOG_PEER_CONNECTIONS=false + # LIBP2P_LOGGING=warn ``` ## Run (Local Executable) @@ -107,6 +130,46 @@ INFO[2025-07-10T17:16:04+05:30] Listening on addresses: [/ip4/127.0.0.1/tcp/4001 From the example above, a full multiaddress to use for other nodes would be: `/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWCNsSau1o9MeMVpHudvHaZRLESRcaGVK9FPKhdLU36BtF` +## Debugging Memory / Performance + +### Periodic Status Logs + +Every 60 seconds the node logs a status line with key metrics: + +``` +Status: connected=150 peerstore=152 dht_rt=20 goroutines=45 heap_alloc=28MB heap_inuse=32MB sys=55MB +``` + +| Metric | What to watch for | +|---|---| +| `peerstore` growing >> `connected` | Peerstore GC not cleaning fast enough | +| `dht_rt` growing unbounded | DHT routing table accumulating entries | +| `goroutines` growing | Goroutine leak | +| `heap_alloc` growing while others stable | Leak in libp2p internals (gossipsub, relay, etc.) | + +### pprof Endpoint + +Set `PPROF_PORT=6060` in your `.env` file to enable the Go pprof debug server. The port is already wired in `docker-compose.yaml`. + +```bash +# Heap profile — what's using memory right now +go tool pprof http://localhost:6060/debug/pprof/heap + +# Allocations — what's been allocating the most over time +go tool pprof -alloc_space http://localhost:6060/debug/pprof/heap + +# Compare two snapshots to find what grew (most useful) +curl -o heap1.pb.gz http://localhost:6060/debug/pprof/heap +# ... wait 30 min ... +curl -o heap2.pb.gz http://localhost:6060/debug/pprof/heap +go tool pprof -base heap1.pb.gz heap2.pb.gz + +# Goroutine dump +curl http://localhost:6060/debug/pprof/goroutine?debug=2 +``` + +The pprof diff (`-base`) is the most powerful — it shows exactly which allocations grew in the window, narrowing down whether the source is peerstore, DHT, gossipsub, relay, or something else. + ## Usage with Other Nodes To configure other libp2p nodes (like the `snapshotter-lite-local-collector` or `submission-topic-watcher`) to use this bootstrap node, you typically pass its full multiaddress via a command-line flag or environment variable (e.g., `--bootstrap` flag for the watcher, or `BOOTSTRAP_NODE_ADDR` environment variable for the collector). \ No newline at end of file diff --git a/build-docker.sh b/build-docker.sh index f07c34f..7e19b0d 100755 --- a/build-docker.sh +++ b/build-docker.sh @@ -1,4 +1,14 @@ #!/bin/bash +# Detect docker compose command (docker compose plugin vs docker-compose standalone) +if docker compose version >/dev/null 2>&1; then + DOCKER_COMPOSE="docker compose" +elif docker-compose version >/dev/null 2>&1; then + DOCKER_COMPOSE="docker-compose" +else + echo "Error: Neither 'docker compose' nor 'docker-compose' found. Please install Docker Compose." + exit 1 +fi + # Build the Docker image using docker-compose -docker-compose build \ No newline at end of file +$DOCKER_COMPOSE build \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go index 995decb..3f79aad 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -6,8 +6,11 @@ import ( "encoding/hex" "flag" "fmt" + "net/http" + _ "net/http/pprof" "os" "os/signal" + "runtime" "strconv" "submissions-bootstrap-node/pkg/config" "submissions-bootstrap-node/pkg/service" @@ -100,8 +103,10 @@ func main() { return } - // Load config cfg := config.LoadConfig() + log.Infof("Bootstrap config: conn_water=%d/%d relay=%t relay_slots=%d rcmgr_mem_mb=%d log_peer_conns=%t", + cfg.ConnManagerLowWater, cfg.ConnManagerHighWater, + cfg.EnableRelayService, cfg.RelayMaxReservations, cfg.RcmgrMemoryLimitMB, cfg.LogPeerConnections) // Create a context that is canceled on a graceful shutdown signal ctx, cancel := context.WithCancel(context.Background()) @@ -117,9 +122,20 @@ func main() { log.Infof("🚀 Bootstrap node started. ID: %s", node.Host.ID().String()) log.Infof("🌍 Listening on addresses: %s", node.Host.Addrs()) - // Start periodic peer logging + // Start pprof debug server if PPROF_PORT is set + if pprofPort := os.Getenv("PPROF_PORT"); pprofPort != "" { + go func() { + addr := ":" + pprofPort + log.Infof("Starting pprof server on %s", addr) + if err := http.ListenAndServe(addr, nil); err != nil { + log.Errorf("pprof server failed: %v", err) + } + }() + } + + // Start periodic peer and memory logging go func() { - ticker := time.NewTicker(60 * time.Second) // Log every 10 seconds + ticker := time.NewTicker(60 * time.Second) defer ticker.Stop() for { select { @@ -127,7 +143,15 @@ func main() { return case <-ticker.C: peers := node.Host.Network().Peers() - log.Infof("Connected peers: %d", len(peers)) + peerstoreSize := len(node.Host.Peerstore().Peers()) + dhtSize := node.DHT.RoutingTable().Size() + + var m runtime.MemStats + runtime.ReadMemStats(&m) + + log.Infof("Status: connected=%d peerstore=%d dht_rt=%d goroutines=%d heap_alloc=%dMB heap_inuse=%dMB sys=%dMB", + len(peers), peerstoreSize, dhtSize, runtime.NumGoroutine(), + m.HeapAlloc/1024/1024, m.HeapInuse/1024/1024, m.Sys/1024/1024) for _, p := range peers { log.Debugf(" - %s", p.String()) } @@ -139,8 +163,11 @@ func main() { sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) <-sigs + signal.Stop(sigs) fmt.Println() log.Info("Shutting down bootstrap node...") - node.Host.Close() + if err := node.Close(); err != nil { + log.Errorf("Error during shutdown: %v", err) + } } diff --git a/docker-compose.yaml b/docker-compose.yaml index 4878e75..50643c9 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: bootstrap-node: build: @@ -7,6 +5,7 @@ services: dockerfile: Dockerfile ports: - "${BOOTSTRAP_PORT:-4001}:${BOOTSTRAP_PORT:-4001}" + - "${PPROF_PORT:-6060}:${PPROF_PORT:-6060}" env_file: - ./.env restart: unless-stopped @@ -17,3 +16,10 @@ services: environment: - LOG_FILE=/app/logs/bootstrap-node.log - LOG_LEVEL=${LOG_LEVEL:-info} + - PPROF_PORT=${PPROF_PORT:-6060} + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "5" + compress: "true" diff --git a/go.mod b/go.mod index 4d1e5e1..71874e5 100644 --- a/go.mod +++ b/go.mod @@ -6,9 +6,7 @@ require ( github.com/ipfs/go-log/v2 v2.8.1 github.com/libp2p/go-libp2p v0.43.0 github.com/libp2p/go-libp2p-kad-dht v0.34.0 - github.com/libp2p/go-libp2p-pubsub v0.14.2 github.com/multiformats/go-multiaddr v0.16.1 - github.com/powerloom/snapshot-sequencer-validator v0.0.0-20250901113836-e70d23e8c3cd github.com/sirupsen/logrus v1.9.3 gopkg.in/natefinch/lumberjack.v2 v2.2.1 ) @@ -24,12 +22,10 @@ require ( github.com/francoispqt/gojay v1.2.13 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gopacket v1.1.19 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/huin/goupnp v1.3.0 // indirect github.com/ipfs/boxo v0.34.0 // indirect github.com/ipfs/go-cid v0.5.0 // indirect diff --git a/go.sum b/go.sum index 156e331..075128c 100644 --- a/go.sum +++ b/go.sum @@ -51,8 +51,6 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= @@ -82,8 +80,6 @@ github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:Fecb github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= github.com/ipfs/boxo v0.34.0 h1:pMP9bAsTs4xVh8R0ZmxIWviV7kjDa60U24QrlGgHb1g= @@ -111,7 +107,6 @@ github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCV github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= @@ -142,8 +137,6 @@ github.com/libp2p/go-libp2p-kad-dht v0.34.0 h1:yvJ/Vrt36GVjsqPxiGcuuwOloKuZLV9Aa github.com/libp2p/go-libp2p-kad-dht v0.34.0/go.mod h1:JNbkES4W5tajS6uYivw6MPs0842cPHAwhgaPw8sQG4o= github.com/libp2p/go-libp2p-kbucket v0.8.0 h1:QAK7RzKJpYe+EuSEATAaaHYMYLkPDGC18m9jxPLnU8s= github.com/libp2p/go-libp2p-kbucket v0.8.0/go.mod h1:JMlxqcEyKwO6ox716eyC0hmiduSWZZl6JY93mGaaqc4= -github.com/libp2p/go-libp2p-pubsub v0.14.2 h1:nT5lFHPQOFJcp9CW8hpKtvbpQNdl2udJuzLQWbgRum8= -github.com/libp2p/go-libp2p-pubsub v0.14.2/go.mod h1:MKPU5vMI8RRFyTP0HfdsF9cLmL1nHAeJm44AxJGJx44= github.com/libp2p/go-libp2p-record v0.3.1 h1:cly48Xi5GjNw5Wq+7gmjfBiG9HCzQVkiZOUZ8kUl+Fg= github.com/libp2p/go-libp2p-record v0.3.1/go.mod h1:T8itUkLcWQLCYMqtX7Th6r7SexyUJpIyPgks757td/E= github.com/libp2p/go-libp2p-routing-helpers v0.7.5 h1:HdwZj9NKovMx0vqq6YNPTh6aaNzey5zHD7HeLJtq6fI= @@ -259,8 +252,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/polydawn/refmt v0.89.0 h1:ADJTApkvkeBZsN0tBTx8QjpD9JkmxbKp0cxfr9qszm4= github.com/polydawn/refmt v0.89.0/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= -github.com/powerloom/snapshot-sequencer-validator v0.0.0-20250901113836-e70d23e8c3cd h1:eXLWAo8YbfgdvGk1QYlEVjMJRGqx/D4vCwWwzLXcge8= -github.com/powerloom/snapshot-sequencer-validator v0.0.0-20250901113836-e70d23e8c3cd/go.mod h1:5HzYNebkR1tHQSEislFyb4WxOYi747yOox7PKBxxgzI= github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= @@ -340,8 +331,6 @@ github.com/whyrusleeping/go-keyspace v0.0.0-20160322163242-5b898ac5add1/go.mod h github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= @@ -372,7 +361,6 @@ golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200602180216-279210d13fed/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= @@ -388,8 +376,6 @@ golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTk golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= @@ -405,8 +391,6 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -427,8 +411,6 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -441,7 +423,6 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -484,16 +465,12 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= diff --git a/pkg/config/config.go b/pkg/config/config.go index 3632e71..a185201 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -8,38 +8,54 @@ import ( ) type Config struct { - PrivateKey string - ConnManagerLowWater int - ConnManagerHighWater int - PublicIP string + PrivateKey string + ConnManagerLowWater int + ConnManagerHighWater int + PublicIP string + EnableRelayService bool + RelayMaxReservations int + RelayMaxReservationsPerIP int + RelayMaxCircuits int + RcmgrMemoryLimitMB int + LogPeerConnections bool } func LoadConfig() Config { return Config{ - PrivateKey: os.Getenv("PRIVATE_KEY"), - // Bootstrap nodes need more connections than regular nodes for discovery - ConnManagerLowWater: getEnvAsInt("CONN_MANAGER_LOW_WATER", 500), - ConnManagerHighWater: getEnvAsInt("CONN_MANAGER_HIGH_WATER", 2000), - PublicIP: os.Getenv("PUBLIC_IP"), + PrivateKey: os.Getenv("PRIVATE_KEY"), + ConnManagerLowWater: getEnvAsInt("CONN_MANAGER_LOW_WATER", 32), + ConnManagerHighWater: getEnvAsInt("CONN_MANAGER_HIGH_WATER", 256), + PublicIP: os.Getenv("PUBLIC_IP"), + EnableRelayService: getEnvAsBool("ENABLE_RELAY_SERVICE", true), + RelayMaxReservations: getEnvAsInt("RELAY_MAX_RESERVATIONS", 256), + RelayMaxReservationsPerIP: getEnvAsInt("RELAY_MAX_RESERVATIONS_PER_IP", 32), + RelayMaxCircuits: getEnvAsInt("RELAY_MAX_CIRCUITS", 8), + RcmgrMemoryLimitMB: getEnvAsInt("RCMGR_MEMORY_LIMIT_MB", 512), + LogPeerConnections: getEnvAsBool("LOG_PEER_CONNECTIONS", false), } } -func getEnv(key, defaultValue string) string { - value := os.Getenv(key) - if value == "" { +func getEnvAsInt(key string, defaultValue int) int { + valueStr := os.Getenv(key) + if valueStr == "" { + return defaultValue + } + value, err := strconv.Atoi(valueStr) + if err != nil { + log.Warnf("Invalid integer value for environment variable %s: %s. Using default value %d", key, valueStr, defaultValue) return defaultValue } return value } -func getEnvAsInt(key string, defaultValue int) int { +func getEnvAsBool(key string, defaultValue bool) bool { valueStr := os.Getenv(key) if valueStr == "" { return defaultValue } - value, err := strconv.Atoi(valueStr) + value, err := strconv.ParseBool(valueStr) if err != nil { - log.Warnf("Invalid integer value for environment variable %s: %s. Using default value %d", key, valueStr, defaultValue) + log.Warnf("Invalid boolean value for environment variable %s: %s. Using default %t", key, valueStr, defaultValue) return defaultValue } return value diff --git a/pkg/service/bootstrap.go b/pkg/service/bootstrap.go index 977b05c..ddf7c81 100644 --- a/pkg/service/bootstrap.go +++ b/pkg/service/bootstrap.go @@ -11,14 +11,14 @@ import ( "github.com/libp2p/go-libp2p" dht "github.com/libp2p/go-libp2p-kad-dht" - pubsub "github.com/libp2p/go-libp2p-pubsub" - "github.com/powerloom/snapshot-sequencer-validator/pkgs/gossipconfig" "github.com/libp2p/go-libp2p/core/crypto" "github.com/libp2p/go-libp2p/core/host" "github.com/libp2p/go-libp2p/core/network" + "github.com/libp2p/go-libp2p/core/peer" "github.com/libp2p/go-libp2p/core/routing" rcmgr "github.com/libp2p/go-libp2p/p2p/host/resource-manager" "github.com/libp2p/go-libp2p/p2p/net/connmgr" + "github.com/libp2p/go-libp2p/p2p/protocol/circuitv2/relay" "github.com/libp2p/go-libp2p/p2p/security/noise" libp2ptls "github.com/libp2p/go-libp2p/p2p/security/tls" "github.com/libp2p/go-libp2p/p2p/transport/tcp" @@ -26,92 +26,116 @@ import ( log "github.com/sirupsen/logrus" ) -// BootstrapNode struct holds the libp2p host and the DHT +// BootstrapNode is a discovery-only libp2p entry point: stable peer ID, DHT routing, +// and optional circuit relay. It does not run gossipsub — mesh traffic stays on +// snapshotters and validators. type BootstrapNode struct { - Host host.Host - DHT *dht.IpfsDHT + Host host.Host + DHT *dht.IpfsDHT + notificationBundle *network.NotifyBundle + ctx context.Context + cancel context.CancelFunc } -// NewBootstrapNode creates and initializes a new libp2p host configured as a bootstrap node +// NewBootstrapNode creates and initializes a new libp2p host configured as a bootstrap node. func NewBootstrapNode(ctx context.Context, port int, cfg config.Config) (*BootstrapNode, error) { + hostCtx, cancel := context.WithCancel(ctx) var priv crypto.PrivKey var err error if cfg.PrivateKey != "" { privBytes, err := hex.DecodeString(cfg.PrivateKey) if err != nil { + cancel() return nil, fmt.Errorf("failed to decode private key: %w", err) } priv, err = crypto.UnmarshalEd25519PrivateKey(privBytes) if err != nil { + cancel() return nil, fmt.Errorf("failed to unmarshal private key: %w", err) } } else { priv, _, err = crypto.GenerateEd25519Key(rand.Reader) if err != nil { + cancel() return nil, fmt.Errorf("failed to generate private key: %w", err) } } - // 1. Create a new resource manager with custom limits. + if cfg.ConnManagerHighWater < cfg.ConnManagerLowWater { + return nil, fmt.Errorf("CONN_MANAGER_HIGH_WATER (%d) must be >= CONN_MANAGER_LOW_WATER (%d)", + cfg.ConnManagerHighWater, cfg.ConnManagerLowWater) + } + + connLimit := cfg.ConnManagerHighWater + 50 + memoryLimit := int64(cfg.RcmgrMemoryLimitMB) << 20 + scalingLimits := rcmgr.DefaultLimits + libp2p.SetDefaultServiceLimits(&scalingLimits) limitsCfg := rcmgr.PartialLimitConfig{ System: rcmgr.ResourceLimits{ - StreamsOutbound: rcmgr.Unlimited, - StreamsInbound: rcmgr.Unlimited, - Streams: rcmgr.Unlimited, - Conns: rcmgr.Unlimited, - ConnsOutbound: rcmgr.Unlimited, - ConnsInbound: rcmgr.Unlimited, - FD: rcmgr.Unlimited, - Memory: rcmgr.LimitVal64(rcmgr.Unlimited), + Conns: rcmgr.LimitVal(connLimit), + ConnsInbound: rcmgr.LimitVal(connLimit), + ConnsOutbound: rcmgr.LimitVal(connLimit), + Streams: rcmgr.LimitVal(4096), + StreamsInbound: rcmgr.LimitVal(2048), + StreamsOutbound: rcmgr.LimitVal(2048), + Memory: rcmgr.LimitVal64(memoryLimit), + FD: rcmgr.LimitVal(connLimit * 4), }, Transient: rcmgr.ResourceLimits{ - StreamsOutbound: rcmgr.Unlimited, - StreamsInbound: rcmgr.Unlimited, - Streams: rcmgr.Unlimited, - Conns: rcmgr.Unlimited, - ConnsOutbound: rcmgr.Unlimited, - ConnsInbound: rcmgr.Unlimited, - FD: rcmgr.Unlimited, - Memory: rcmgr.LimitVal64(rcmgr.Unlimited), + Conns: rcmgr.LimitVal(connLimit), + ConnsInbound: rcmgr.LimitVal(connLimit), + ConnsOutbound: rcmgr.LimitVal(connLimit), + Streams: rcmgr.LimitVal(1024), + StreamsInbound: rcmgr.LimitVal(512), + StreamsOutbound: rcmgr.LimitVal(512), + Memory: rcmgr.LimitVal64(memoryLimit / 4), + FD: rcmgr.LimitVal(connLimit * 2), }, } limiter := rcmgr.NewFixedLimiter(limitsCfg.Build(scalingLimits.AutoScale())) rscMgr, err := rcmgr.NewResourceManager(limiter, rcmgr.WithMetricsDisabled()) if err != nil { + cancel() return nil, fmt.Errorf("failed to create resource manager: %w", err) } - // Create a connection manager. connMgr, err := connmgr.NewConnManager( - cfg.ConnManagerLowWater, // Lowwater - cfg.ConnManagerHighWater, // Highwater + cfg.ConnManagerLowWater, + cfg.ConnManagerHighWater, connmgr.WithGracePeriod(time.Minute), ) if err != nil { + cancel() return nil, fmt.Errorf("failed to create connection manager: %w", err) } var kadDHT *dht.IpfsDHT - // Create the libp2p host options opts := []libp2p.Option{ libp2p.ListenAddrStrings(fmt.Sprintf("/ip4/0.0.0.0/tcp/%d", port)), libp2p.Identity(priv), libp2p.ResourceManager(rscMgr), libp2p.ConnectionManager(connMgr), libp2p.Routing(func(h host.Host) (routing.PeerRouting, error) { - kadDHT, err = dht.New(ctx, h, dht.Mode(dht.ModeServer)) + kadDHT, err = dht.New(hostCtx, h, dht.Mode(dht.ModeServer)) return kadDHT, err }), - libp2p.EnableRelayService(), libp2p.ForceReachabilityPublic(), libp2p.Security(noise.ID, noise.New), libp2p.Security(libp2ptls.ID, libp2ptls.New), libp2p.Transport(tcp.NewTCPTransport), } + if cfg.EnableRelayService { + relayResources := relay.DefaultResources() + relayResources.MaxReservations = cfg.RelayMaxReservations + relayResources.MaxReservationsPerIP = cfg.RelayMaxReservationsPerIP + relayResources.MaxCircuits = cfg.RelayMaxCircuits + opts = append(opts, libp2p.EnableRelayService(relay.WithResources(relayResources))) + log.Infof("Circuit relay enabled (max_reservations=%d per_ip=%d max_circuits=%d)", + cfg.RelayMaxReservations, cfg.RelayMaxReservationsPerIP, cfg.RelayMaxCircuits) + } - // Add public IP address if configured if cfg.PublicIP != "" { publicAddr, err := multiaddr.NewMultiaddr(fmt.Sprintf("/ip4/%s/tcp/%d", cfg.PublicIP, port)) if err != nil { @@ -123,43 +147,102 @@ func NewBootstrapNode(ctx context.Context, port int, cfg config.Config) (*Bootst } } - // Create the libp2p host with the DHT in server mode. h, err := libp2p.New(opts...) if err != nil { + cancel() return nil, fmt.Errorf("failed to create libp2p host: %w", err) } - // Get standardized gossipsub parameters for consistency across network - gossipParams, peerScoreParams, peerScoreThresholds, paramHash := gossipconfig.ConfigureSnapshotSubmissionsMesh(h.ID()) - - // Create a new GossipSub instance with standardized parameters - _, err = pubsub.NewGossipSub(ctx, h, - pubsub.WithGossipSubParams(*gossipParams), - pubsub.WithPeerScore(peerScoreParams, peerScoreThresholds), - pubsub.WithFloodPublish(true), - pubsub.WithMessageSignaturePolicy(pubsub.StrictSign), - ) - if err != nil { - return nil, fmt.Errorf("failed to create pubsub: %w", err) + log.Infof("Libp2p bootstrap host ID: %s, listening on: %v", h.ID(), h.Addrs()) + log.Infof("DHT routing table size: %d (relay=%t conn_high=%d memory_limit_mb=%d)", + kadDHT.RoutingTable().Size(), cfg.EnableRelayService, cfg.ConnManagerHighWater, cfg.RcmgrMemoryLimitMB) + + var notificationBundle *network.NotifyBundle + if cfg.LogPeerConnections { + notificationBundle = &network.NotifyBundle{ + ConnectedF: func(_ network.Network, conn network.Conn) { + log.Infof("Peer connected: %s, addr: %s", conn.RemotePeer(), conn.RemoteMultiaddr()) + }, + DisconnectedF: func(_ network.Network, conn network.Conn) { + log.Infof("Peer disconnected: %s, addr: %s", conn.RemotePeer(), conn.RemoteMultiaddr()) + }, + } + h.Network().Notify(notificationBundle) } - - log.Infof("🔑 Gossipsub parameter hash: %s (bootstrap node)", paramHash) - log.Infof("Libp2p host created with ID: %s, listening on: %v", h.ID(), h.Addrs()) - log.Infof("Bootstrap node DHT routing table size: %d", kadDHT.RoutingTable().Size()) - log.Infof("Bootstrap node created with ID: %s, listening on: %v", h.ID(), h.Addrs()) + node := &BootstrapNode{ + Host: h, + DHT: kadDHT, + notificationBundle: notificationBundle, + ctx: hostCtx, + cancel: cancel, + } - h.Network().Notify(&network.NotifyBundle{ - ConnectedF: func(_ network.Network, conn network.Conn) { - log.Infof("Bootstrap Peer connected: %s, Addr: %s", conn.RemotePeer(), conn.RemoteMultiaddr()) - }, - DisconnectedF: func(_ network.Network, conn network.Conn) { - log.Infof("Bootstrap Peer disconnected: %s, Addr: %s", conn.RemotePeer(), conn.RemoteMultiaddr()) - }, - }) + go node.startPeerstoreGC() - return &BootstrapNode{ - Host: h, - DHT: kadDHT, - }, nil + return node, nil +} + +// Close closes the bootstrap node and releases all resources. +func (n *BootstrapNode) Close() error { + var errs []error + + if n.cancel != nil { + n.cancel() + } + + if n.notificationBundle != nil && n.Host != nil { + n.Host.Network().StopNotify(n.notificationBundle) + } + + if n.DHT != nil { + if err := n.DHT.Close(); err != nil { + errs = append(errs, fmt.Errorf("failed to close DHT: %w", err)) + } + } + + if n.Host != nil { + if err := n.Host.Close(); err != nil { + errs = append(errs, fmt.Errorf("failed to close host: %w", err)) + } + } + + if len(errs) > 0 { + return fmt.Errorf("errors during shutdown: %v", errs) + } + return nil +} + +func (n *BootstrapNode) startPeerstoreGC() { + ticker := time.NewTicker(2 * time.Minute) + defer ticker.Stop() + for { + select { + case <-n.ctx.Done(): + return + case <-ticker.C: + peers := n.Host.Peerstore().Peers() + connectedPeers := n.Host.Network().Peers() + connectedSet := make(map[peer.ID]struct{}, len(connectedPeers)) + for _, p := range connectedPeers { + connectedSet[p] = struct{}{} + } + removed := 0 + for _, p := range peers { + if p == n.Host.ID() { + continue + } + if _, connected := connectedSet[p]; connected { + continue + } + n.Host.Peerstore().ClearAddrs(p) + n.Host.Peerstore().RemovePeer(p) + removed++ + } + remaining := len(n.Host.Peerstore().Peers()) + if removed > 0 { + log.Infof("Peerstore GC: removed %d stale peers, %d remaining (connected: %d)", removed, remaining, len(connectedPeers)) + } + } + } } diff --git a/start.sh b/start.sh index 85e97d9..1ab162d 100755 --- a/start.sh +++ b/start.sh @@ -1,11 +1,50 @@ #!/bin/bash -# If a port is supplied as the first argument, export it so that it overrides -# the value in .env. Otherwise, rely entirely on Docker Compose’s .env handling. -if [ -n "$1" ]; then - export BOOTSTRAP_PORT="$1" +# Detect docker compose command (docker compose plugin vs docker-compose standalone) +if docker compose version >/dev/null 2>&1; then + DOCKER_COMPOSE="docker compose" +elif docker-compose version >/dev/null 2>&1; then + DOCKER_COMPOSE="docker-compose" +else + echo "Error: Neither 'docker compose' nor 'docker-compose' found. Please install Docker Compose." + exit 1 fi -echo "Starting bootstrap node (port: ${BOOTSTRAP_PORT:-})" +# Parse arguments +BUILD_IMAGE=false +PORT_ARG="" -docker-compose up +while [ $# -gt 0 ]; do + case "$1" in + --build) + BUILD_IMAGE=true + shift + ;; + *) + # Treat any non-flag argument as a port number + PORT_ARG="$1" + shift + ;; + esac +done + +# Build image if requested +if [ "$BUILD_IMAGE" = true ]; then + echo "Building Docker image..." + $DOCKER_COMPOSE build + if [ $? -ne 0 ]; then + echo "Error: Docker build failed" + exit 1 + fi + echo "Build complete" +fi + +# Trap SIGINT and SIGTERM to ensure proper cleanup +trap "echo 'Stopping bootstrap node...'; $DOCKER_COMPOSE down; exit 0" SIGINT SIGTERM + +# If a port is supplied, export it so that it overrides the value in .env +if [ -n "$PORT_ARG" ]; then + export BOOTSTRAP_PORT="$PORT_ARG" +fi + +$DOCKER_COMPOSE up diff --git a/stop.sh b/stop.sh index f163d8d..250b830 100755 --- a/stop.sh +++ b/stop.sh @@ -1,3 +1,13 @@ #!/bin/bash -docker-compose down +# Detect docker compose command (docker compose plugin vs docker-compose standalone) +if docker compose version >/dev/null 2>&1; then + DOCKER_COMPOSE="docker compose" +elif docker-compose version >/dev/null 2>&1; then + DOCKER_COMPOSE="docker-compose" +else + echo "Error: Neither 'docker compose' nor 'docker-compose' found. Please install Docker Compose." + exit 1 +fi + +$DOCKER_COMPOSE down