diff --git a/custom-recipes/buildernet/mkosi/.gitignore b/custom-recipes/buildernet/mkosi/.gitignore new file mode 100644 index 00000000..13500e09 --- /dev/null +++ b/custom-recipes/buildernet/mkosi/.gitignore @@ -0,0 +1,2 @@ +.flashbots-images/ +.runtime/ diff --git a/custom-recipes/buildernet/mkosi/README.md b/custom-recipes/buildernet/mkosi/README.md new file mode 100644 index 00000000..5dcbb960 --- /dev/null +++ b/custom-recipes/buildernet/mkosi/README.md @@ -0,0 +1,106 @@ +# BuilderNet VM Scripts + +Temporary bash scripts for running BuilderNet VM alongside builder-playground. +Feel free to translate them to Go and integrate into the CLI. + +## Information: + +Changes are made in two repos: +- [flashbots-images](https://github.com/flashbots/flashbots-images/tree/fryd/mkosi-playground) on `fryd/mkosi-playground` branch, +- [builder-playground](https://github.com/flashbots/builder-playground/tree/fryd/mkosi-playground) on `fryd/mkosi-playground` branch, + +There are no plans of using [buildernet-playground](https://github.com/flashbots/buildernet-playground) repo. + +## Start playground + +Start playground with: `--bind-external` (expose ports to the VM) and `--contender` (create transactions) + +```bash +builder-playground \ + start buildernet \ + --bind-external \ + --output ".playground-home" \ + --contender \ + --detached fryd-vm-builder +``` + +## First Time Setup + +```bash +./sync.sh # Clone / fetch flashbots-images repo +./build.sh # Build VM image with mkosi +./prepare.sh # Extract image + create data disk +``` + +`sync.sh` clones/updates the `fryd/mkosi-playground` branch of flashbots-images. + +## Run VM + +```bash +./start.sh # Start VM (background) +./console.sh # Open console to the VM +./ssh.sh # SSH into running VM (requires SSH key setup) +./stop.sh # Stop VM +``` + +## Builder Hub + +```bash +./builderhub-configure.sh # Register VM with builder-hub and update config for the VM +./builderhub-get-config.sh # Get configuration for the VM +``` + +## Operator API + +Scripts to interact with the operator-api service running inside the VM. + +> **Note:** Actions and File Uploads could potentially be used for various things, like injecting genesis config instead of BuilderHub - still exploring this functionality. + +```bash +./operator-api-health.sh # Check if operator-api is healthy +./operator-api-logs.sh # Get event logs +./operator-api-action.sh # Execute an action +``` + +Available actions: +- `reboot` - Reboot the system +- `rbuilder_restart` - Restart rbuilder-operator service +- `rbuilder_stop` - Stop rbuilder-operator service +- `fetch_config` - Fetch config from BuilderHub +- `rbuilder_bidding_restart` - Restart rbuilder-bidding service +- `ssh_stop` - Stop SSH service +- `ssh_start` - Start SSH service +- `haproxy_restart` - Restart HAProxy service + +### File Uploads + +Upload files to predefined paths. Only whitelisted names from `[file_uploads]` config are allowed: + +```toml +[file_uploads] +rbuilder_blocklist = "/var/lib/persistent/rbuilder-operator/rbuilder.blocklist.json" +``` + +```bash +# Stores local blocklist.json content to the configured remote path +curl -k --data-binary "@blocklist.json" https://localhost:13535/api/v1/file-upload/rbuilder_blocklist +``` + +### Customization + +To add more actions or file uploads, modify the config template: +https://github.com/flashbots/flashbots-images/blob/fryd/mkosi-playground/mkosi.profiles/playground/mkosi.extra/usr/lib/mustache-templates/etc/operator-api/config.toml.mustache + +## Maintenance + +```bash +./sync.sh # Update flashbots-images to latest +./clean.sh # Clean build artifacts + runtime files +``` + +## Ports + +| Port | Service | +|------|---------| +| 2222 | SSH (maps to VM:40192) | +| 13535 | Operator API (maps to VM:3535) | diff --git a/custom-recipes/buildernet/mkosi/build.sh b/custom-recipes/buildernet/mkosi/build.sh new file mode 100755 index 00000000..91aea375 --- /dev/null +++ b/custom-recipes/buildernet/mkosi/build.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Build the BuilderNet VM image +set -eu -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +FLASHBOTS_IMAGES_DIR="${SCRIPT_DIR}/.flashbots-images" + +if [[ ! -d "${FLASHBOTS_IMAGES_DIR}" ]]; then + echo "Error: flashbots-images not found. Run ./sync.sh first." + exit 1 +fi + +make -C "${FLASHBOTS_IMAGES_DIR}" build-playground diff --git a/custom-recipes/buildernet/mkosi/builderhub-get-config.sh b/custom-recipes/buildernet/mkosi/builderhub-get-config.sh new file mode 100755 index 00000000..88477f4b --- /dev/null +++ b/custom-recipes/buildernet/mkosi/builderhub-get-config.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Check builder-hub configuration +set -eu -o pipefail + +echo "Builder Configuration:" +curl -s http://localhost:8888/api/l1-builder/v1/configuration | jq . diff --git a/custom-recipes/buildernet/mkosi/clean.sh b/custom-recipes/buildernet/mkosi/clean.sh new file mode 100755 index 00000000..eb087eb3 --- /dev/null +++ b/custom-recipes/buildernet/mkosi/clean.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Clean build artifacts and runtime files +set -eu -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +FLASHBOTS_IMAGES_DIR="${SCRIPT_DIR}/.flashbots-images" +RUNTIME_DIR="${SCRIPT_DIR}/.runtime" +PIDFILE="${RUNTIME_DIR}/qemu.pid" + +# Check if VM is running +if [[ -f "${PIDFILE}" ]] && kill -0 $(cat "${PIDFILE}") 2>/dev/null; then + echo "Error: VM is still running. Run ./stop.sh first." + exit 1 +fi + +if [[ -d "${FLASHBOTS_IMAGES_DIR}" ]]; then + make -C "${FLASHBOTS_IMAGES_DIR}" clean +fi + +rm -rf "${RUNTIME_DIR}" diff --git a/custom-recipes/buildernet/mkosi/console.sh b/custom-recipes/buildernet/mkosi/console.sh new file mode 100755 index 00000000..a7f73b23 --- /dev/null +++ b/custom-recipes/buildernet/mkosi/console.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Connect to the BuilderNet VM console (auto-login with devtools profile) +set -eu -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONSOLE_SOCK="${SCRIPT_DIR}/.runtime/console.sock" + +if [[ ! -S "${CONSOLE_SOCK}" ]]; then + echo "Error: Console socket not found. Is the VM running?" + echo "Run ./start.sh first." + exit 1 +fi + +echo "Connecting to VM console... (Ctrl+] to exit)" +socat -,raw,echo=0,escape=0x1d UNIX-CONNECT:"${CONSOLE_SOCK}" + +echo "" +echo "Disconnected from VM console." diff --git a/custom-recipes/buildernet/mkosi/operator-api-action.sh b/custom-recipes/buildernet/mkosi/operator-api-action.sh new file mode 100755 index 00000000..3e26f5c0 --- /dev/null +++ b/custom-recipes/buildernet/mkosi/operator-api-action.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Execute an operator-api action +set -eu -o pipefail + +OPERATOR_API_PORT=${OPERATOR_API_PORT:-13535} +OPERATOR_API_HOST=${OPERATOR_API_HOST:-localhost} + +ACTION=${1:-} + +if [ -z "$ACTION" ]; then + echo "Usage: $0 " + echo "" + echo "Available actions:" + echo " reboot - Reboot the system" + echo " rbuilder_restart - Restart rbuilder-operator service" + echo " rbuilder_stop - Stop rbuilder-operator service" + echo " fetch_config - Fetch config from BuilderHub" + echo " rbuilder_bidding_restart - Restart rbuilder-bidding service" + echo " ssh_stop - Stop SSH service" + echo " ssh_start - Start SSH service" + echo " haproxy_restart - Restart HAProxy service" + exit 1 +fi + +curl -s -k "https://${OPERATOR_API_HOST}:${OPERATOR_API_PORT}/api/v1/actions/${ACTION}" diff --git a/custom-recipes/buildernet/mkosi/operator-api-health.sh b/custom-recipes/buildernet/mkosi/operator-api-health.sh new file mode 100755 index 00000000..943f3d03 --- /dev/null +++ b/custom-recipes/buildernet/mkosi/operator-api-health.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Check operator-api health +set -eu -o pipefail + +OPERATOR_API_PORT=${OPERATOR_API_PORT:-13535} +OPERATOR_API_HOST=${OPERATOR_API_HOST:-localhost} + +# /livez returns HTTP 200 with empty body on success +HTTP_CODE=$(curl -s -k -o /dev/null -w "%{http_code}" "https://${OPERATOR_API_HOST}:${OPERATOR_API_PORT}/livez") + +if [ "$HTTP_CODE" = "200" ]; then + echo "OK" + exit 0 +else + echo "FAIL (HTTP $HTTP_CODE)" + exit 1 +fi diff --git a/custom-recipes/buildernet/mkosi/operator-api-logs.sh b/custom-recipes/buildernet/mkosi/operator-api-logs.sh new file mode 100755 index 00000000..f1951178 --- /dev/null +++ b/custom-recipes/buildernet/mkosi/operator-api-logs.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Get operator-api event logs +set -eu -o pipefail + +OPERATOR_API_PORT=${OPERATOR_API_PORT:-13535} +OPERATOR_API_HOST=${OPERATOR_API_HOST:-localhost} + +curl -s -k "https://${OPERATOR_API_HOST}:${OPERATOR_API_PORT}/logs" diff --git a/custom-recipes/buildernet/mkosi/ovmf.sh b/custom-recipes/buildernet/mkosi/ovmf.sh new file mode 100755 index 00000000..d6dfe3e0 --- /dev/null +++ b/custom-recipes/buildernet/mkosi/ovmf.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Discover OVMF firmware files using QEMU firmware descriptors. +# Usage: +# source ovmf.sh +# # Sets OVMF_CODE and OVMF_VARS variables +set -eu -o pipefail + +_discover_ovmf() { + local search_dirs=( + /usr/share/qemu/firmware + /etc/qemu/firmware + ) + + for dir in "${search_dirs[@]}"; do + [[ -d "$dir" ]] || continue + for f in "$dir"/*.json; do + [[ -f "$f" ]] || continue + # Match x86_64 UEFI, non-secure-boot, 4m variant + if jq -e ' + (.targets[] | select(.architecture == "x86_64")) and + (.["interface-types"] | index("uefi")) and + ((.features | index("secure-boot")) | not) + ' "$f" >/dev/null 2>&1; then + OVMF_CODE=$(jq -r '.mapping.executable.filename' "$f") + OVMF_VARS=$(jq -r '.mapping."nvram-template".filename' "$f") + if [[ -f "$OVMF_CODE" && -f "$OVMF_VARS" ]]; then + return 0 + fi + fi + done + done + + # Fallback: check common hardcoded paths + local known_paths=( + "/usr/share/OVMF/x64" + "/usr/share/edk2/x64" + "/usr/share/OVMF" + "/usr/share/edk2/ovmf" + "/usr/share/edk2-ovmf/x64" + ) + for dir in "${known_paths[@]}"; do + if [[ -f "$dir/OVMF_CODE.4m.fd" && -f "$dir/OVMF_VARS.4m.fd" ]]; then + OVMF_CODE="$dir/OVMF_CODE.4m.fd" + OVMF_VARS="$dir/OVMF_VARS.4m.fd" + return 0 + fi + done + + echo "Error: Could not find OVMF firmware files." >&2 + echo "Install the edk2-ovmf package (or equivalent for your distro)." >&2 + return 1 +} + +_discover_ovmf +export OVMF_CODE OVMF_VARS +echo "OVMF_CODE=${OVMF_CODE}" +echo "OVMF_VARS=${OVMF_VARS}" diff --git a/custom-recipes/buildernet/mkosi/playground.yaml b/custom-recipes/buildernet/mkosi/playground.yaml new file mode 100644 index 00000000..68286b40 --- /dev/null +++ b/custom-recipes/buildernet/mkosi/playground.yaml @@ -0,0 +1,14 @@ +base: buildernet +description: Deploy the stack with the BuilderNet mkosi image (QEMU) + +recipe: + builder-vm: + services: + builder: + lifecycle_hooks: true + init: + - rm -rf .runtime/buildernet-vm.qcow2 || true + - ./prepare.sh + start: ./start.sh + stop: + - ./stop.sh diff --git a/custom-recipes/buildernet/mkosi/prepare.sh b/custom-recipes/buildernet/mkosi/prepare.sh new file mode 100755 index 00000000..671ef626 --- /dev/null +++ b/custom-recipes/buildernet/mkosi/prepare.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Extract VM image and create data disk +set -eu -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +FLASHBOTS_IMAGES_DIR="${SCRIPT_DIR}/.flashbots-images" +RUNTIME_DIR="${SCRIPT_DIR}/.runtime" + +QEMU_QCOW2="${FLASHBOTS_IMAGES_DIR}/mkosi.output/buildernet-qemu_latest.qcow2" + +VM_IMAGE="${RUNTIME_DIR}/buildernet-vm.qcow2" +VM_DATA_DISK="${RUNTIME_DIR}/persistent.raw" + +if [[ ! -f "${QEMU_QCOW2}" ]]; then + echo "Error: QEMU qcow2 image not found: ${QEMU_QCOW2}" + echo "Run ./build.sh first." + exit 1 +fi + +rm -rf "${RUNTIME_DIR}" +mkdir -p "${RUNTIME_DIR}" + +rm -f "${VM_IMAGE}" +cp --sparse=always "${QEMU_QCOW2}" "${VM_IMAGE}" + +qemu-img create -f raw "${VM_DATA_DISK}" 100G + +echo "Runtime ready: ${RUNTIME_DIR}" +ls -lah "${RUNTIME_DIR}" diff --git a/custom-recipes/buildernet/mkosi/send-tx.sh b/custom-recipes/buildernet/mkosi/send-tx.sh new file mode 100755 index 00000000..29421823 --- /dev/null +++ b/custom-recipes/buildernet/mkosi/send-tx.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash + +FROM_ADDR="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + +CHAIN_ID_HEX=$(curl -s -X POST http://localhost:8545 \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}' | jq -r '.result') +CHAIN_ID=$((CHAIN_ID_HEX)) + +NONCE_HEX=$(curl -s -X POST http://localhost:8545 \ + -H "Content-Type: application/json" \ + -d "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getTransactionCount\",\"params\":[\"$FROM_ADDR\",\"pending\"],\"id\":1}" | jq -r '.result') +NONCE=$((NONCE_HEX)) + +# Tx: Send the builder 0x74085Fbe5108CF75F91951DDfD06d3f7d6890EF7 with 0.1 ether +TX_PAYLOAD=$(cast mktx \ + --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ + 0x74085Fbe5108CF75F91951DDfD06d3f7d6890EF7 \ + --value 0.1ether --nonce "$NONCE" --gas-limit 21000 --gas-price 1gwei --chain "$CHAIN_ID") + +# Change this to set the target. 8545 for playground reth, 18645 for buildernet vm rbuilder. +date +%H:%M:%S +SEND_RESULT=$(curl -s --fail-with-body -X POST http://localhost:8545 \ + -H "Content-Type: application/json" \ + -d "{\"jsonrpc\":\"2.0\",\"method\":\"eth_sendRawTransaction\",\"params\":[\"$TX_PAYLOAD\"],\"id\":1}") +CURL_EXIT=$? + +if [ $CURL_EXIT -ne 0 ]; then + echo "Error: curl failed with exit code $CURL_EXIT" + exit 1 +fi + +echo "Send result: $SEND_RESULT" + +TX_HASH=$(echo "$SEND_RESULT" | jq -r '.result') + +if [ -z "$TX_HASH" ] || [ "$TX_HASH" = "null" ]; then + echo "Error: Failed to get transaction hash" + echo "$SEND_RESULT" | jq . + exit 1 +fi + +echo "TX_HASH: $TX_HASH" + +echo "Waiting for receipt..." +while true; do + RECEIPT=$(curl -s -X POST http://localhost:8545 \ + -H "Content-Type: application/json" \ + -d "{\"jsonrpc\":\"2.0\",\"method\":\"eth_getTransactionReceipt\",\"params\":[\"$TX_HASH\"],\"id\":1}") + + RESULT=$(echo "$RECEIPT" | jq -r '.result') + if [ "$RESULT" != "null" ]; then + echo "Receipt: $RECEIPT" + break + fi + sleep 1 +done diff --git a/custom-recipes/buildernet/mkosi/ssh.sh b/custom-recipes/buildernet/mkosi/ssh.sh new file mode 100755 index 00000000..2f275024 --- /dev/null +++ b/custom-recipes/buildernet/mkosi/ssh.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# SSH into the BuilderNet VM +set -eu -o pipefail + +SSH_PORT=2222 + +ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p ${SSH_PORT} bnet@localhost diff --git a/custom-recipes/buildernet/mkosi/start.sh b/custom-recipes/buildernet/mkosi/start.sh new file mode 100755 index 00000000..bfdf9cc7 --- /dev/null +++ b/custom-recipes/buildernet/mkosi/start.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Start the BuilderNet VM +set -eu -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +RUNTIME_DIR="${SCRIPT_DIR}/.runtime" +VM_IMAGE="${RUNTIME_DIR}/buildernet-vm.qcow2" +VM_DATA_DISK="${RUNTIME_DIR}/persistent.raw" +PIDFILE="${RUNTIME_DIR}/qemu.pid" +CONSOLE_LOG="${RUNTIME_DIR}/console.log" +CONSOLE_SOCK="${RUNTIME_DIR}/console.sock" + +CPU=8 +RAM=32G +SSH_PORT=2222 +OPERATOR_API_PORT=13535 +RBUILDER_RPC_PORT=18645 + +if [[ ! -f "${VM_IMAGE}" ]]; then + echo "Error: VM image not found. Run ./prepare.sh first." + exit 1 +fi + +if [[ -f "${PIDFILE}" ]] && kill -0 $(cat "${PIDFILE}") 2>/dev/null; then + echo "Error: VM already running (PID: $(cat ${PIDFILE}))" + exit 1 +fi + +echo "Starting VM..." +echo " SSH: localhost:${SSH_PORT}" +echo " Operator API: localhost:${OPERATOR_API_PORT}" +echo " rbuilder RPC: localhost:${RBUILDER_RPC_PORT}" +echo " Console log: ${CONSOLE_LOG}" +echo " Console socket: ${CONSOLE_SOCK}" + +source "${SCRIPT_DIR}/ovmf.sh" + +qemu-system-x86_64 \ + -daemonize \ + -pidfile "${PIDFILE}" \ + -serial file:"${CONSOLE_LOG}" \ + -name buildernet-playground \ + -drive if=pflash,format=raw,readonly=on,file="${OVMF_CODE}" \ + -drive if=pflash,format=raw,readonly=on,file="${OVMF_VARS}" \ + -drive format=qcow2,if=none,cache=none,id=osdisk,file="${VM_IMAGE}" \ + -device nvme,drive=osdisk,serial=nvme-os,bootindex=0 \ + -enable-kvm -cpu host -m "${RAM}" -smp "${CPU}" -display none \ + -device virtio-scsi-pci,id=scsi0 \ + -drive file="${VM_DATA_DISK}",format=raw,if=none,id=datadisk \ + -device nvme,id=nvme0,serial=nvme-data \ + -device nvme-ns,drive=datadisk,bus=nvme0,nsid=12 \ + -nic user,model=virtio-net-pci,hostfwd=tcp:127.0.0.1:${SSH_PORT}-:40192,hostfwd=tcp:127.0.0.1:${OPERATOR_API_PORT}-:3535,hostfwd=tcp:127.0.0.1:${RBUILDER_RPC_PORT}-:8645 \ + -chardev socket,id=virtcon,path="${CONSOLE_SOCK}",server=on,wait=off \ + -device virtio-serial-pci \ + -device virtconsole,chardev=virtcon,name=org.qemu.console.0 + +echo "VM started (PID: $(cat ${PIDFILE}))" +echo "Use './stop.sh' to stop, './console.sh' to connect" +echo "Use 'tail -f ${CONSOLE_LOG}' to watch console output" + + +# TRIED TO DISABLE SERVICES - DID NOT WORK +# error: +# qemu-system-x86_64: -append only allowed with -kernel option + +# PLAYGROUND_DISABLE_SERVICES=( +# reth-sync # Downloads Reth snapshot from S3 bucket +# acme-le # Issues Let's Encrypt TLS certificates +# acme-le-renewal # Renews Let's Encrypt certificates +# rbuilder-bidding-downloader # Downloads binary from private GitHub repo +# vector # Observability pipeline (logs/metrics) +# rbuilder-rebalancer # ETH balance rebalancing across wallets +# operator-api # Management API for node operators +# config-watchdog # Watches and reloads rbuilder config +# ) + +# mask_args() { +# [[ $# -gt 0 ]] && printf "systemd.mask=%s.service " "$@" +# } +# # # add argument to qemu-system-x86_64: +# # \ +# # -append "console=ttyS0 $(mask_args "${PLAYGROUND_DISABLE_SERVICES[@]}")" diff --git a/custom-recipes/buildernet/mkosi/stop.sh b/custom-recipes/buildernet/mkosi/stop.sh new file mode 100755 index 00000000..9c1fd92b --- /dev/null +++ b/custom-recipes/buildernet/mkosi/stop.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Stop the BuilderNet VM +set -eu -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PIDFILE="${SCRIPT_DIR}/.runtime/qemu.pid" + +if [[ ! -f "${PIDFILE}" ]]; then + echo "No pidfile found" + exit 0 +fi + +PID=$(cat "${PIDFILE}") +if kill -0 "${PID}" 2>/dev/null; then + kill "${PID}" + rm "${PIDFILE}" + echo "VM stopped (PID: ${PID})" +else + rm "${PIDFILE}" + echo "Stale pidfile removed (process not running)" +fi \ No newline at end of file diff --git a/custom-recipes/buildernet/mkosi/sync.sh b/custom-recipes/buildernet/mkosi/sync.sh new file mode 100755 index 00000000..a561dfcc --- /dev/null +++ b/custom-recipes/buildernet/mkosi/sync.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Clone or update flashbots-images repository +set -eu -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +FLASHBOTS_IMAGES_DIR="${SCRIPT_DIR}/.flashbots-images" +FLASHBOTS_IMAGES_BRANCH="fryd/mkosi-playground" +FLASHBOTS_IMAGES_REPO="https://github.com/flashbots/flashbots-images.git" + +if [[ ! -d "${FLASHBOTS_IMAGES_DIR}" ]]; then + git clone --branch "${FLASHBOTS_IMAGES_BRANCH}" "${FLASHBOTS_IMAGES_REPO}" "${FLASHBOTS_IMAGES_DIR}" +else + git -C "${FLASHBOTS_IMAGES_DIR}" fetch origin + git -C "${FLASHBOTS_IMAGES_DIR}" checkout "${FLASHBOTS_IMAGES_BRANCH}" + git -C "${FLASHBOTS_IMAGES_DIR}" pull origin "${FLASHBOTS_IMAGES_BRANCH}" +fi diff --git a/docs/recipes/buildernet.md b/docs/recipes/buildernet.md index e06c050f..a3f2aeff 100644 --- a/docs/recipes/buildernet.md +++ b/docs/recipes/buildernet.md @@ -21,22 +21,20 @@ graph LR el["el
rpc:30303
http:8545
ws:8546
authrpc:8551
metrics:9090"] el_healthmon["el_healthmon"] beacon["beacon
p2p:9000
p2p:9000
quic-p2p:9100
http:3500"] - beacon_healthmon["beacon_healthmon"] validator["validator"] mev_boost_relay["mev-boost-relay
http:5555"] builder_hub_db["builder-hub-db
postgres:5432"] builder_hub_api["builder-hub-api
http:8080
admin:8081
internal:8082
metrics:8090"] builder_hub_proxy["builder-hub-proxy
http:8888"] + server["server
http:8100"] el_healthmon -->|http| el beacon -->|authrpc| el beacon -->|http| mev_boost_relay - beacon_healthmon -->|http| beacon validator -->|http| beacon mev_boost_relay -->|http| beacon builder_hub_api -->|postgres| builder_hub_db builder_hub_proxy -->|http| builder_hub_api - mev_boost_relay -.->|depends_on| beacon builder_hub_api -.->|depends_on| builder_hub_db builder_hub_proxy -.->|depends_on| builder_hub_api ``` diff --git a/go.mod b/go.mod index 40ace471..b1686744 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss v1.0.0 github.com/docker/docker v28.0.1+incompatible github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 - github.com/ethereum/go-ethereum v1.16.3 + github.com/ethereum/go-ethereum v1.16.8 github.com/fatih/color v1.18.0 github.com/flashbots/go-boost-utils v1.9.1-0.20250819134059-e5294cb450c9 github.com/flashbots/go-template v1.0.0 @@ -79,7 +79,7 @@ require ( github.com/goccy/go-json v0.10.4 // indirect github.com/gofrs/flock v0.12.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e // indirect + github.com/golang/snappy v1.0.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/gofuzz v1.2.1-0.20220503160820-4a35382e8fc8 // indirect github.com/google/uuid v1.6.0 // indirect @@ -170,7 +170,7 @@ require ( golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect - golang.org/x/time v0.11.0 // indirect + golang.org/x/time v0.9.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect google.golang.org/grpc v1.71.0 // indirect diff --git a/go.sum b/go.sum index f0d99ff0..9ca28c49 100644 --- a/go.sum +++ b/go.sum @@ -202,8 +202,8 @@ github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e h1:4bw4WeyTYPp0smaXiJZCNnLrvVBqirQVreixayXezGc= -github.com/golang/snappy v0.0.5-0.20231225225746-43d5d4cd4e0e/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -567,8 +567,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= diff --git a/main.go b/main.go index 687c2e00..799172a0 100644 --- a/main.go +++ b/main.go @@ -433,7 +433,7 @@ var generateCmd = &cobra.Command{ return fmt.Errorf("file %s already exists. Use --force to overwrite", outFile) } } - if err := os.WriteFile(outFile, []byte(yamlContent), 0o644); err != nil { + if err := os.WriteFile(outFile, []byte(yamlContent), 0o755); err != nil { return fmt.Errorf("failed to write %s: %w", outFile, err) } fmt.Printf("Created %s\n", outFile) @@ -761,6 +761,26 @@ func runIt(recipe playground.Recipe) error { return fmt.Errorf("failed to run docker: %w", err) } + slog.Info("Waiting for services to get healthy... ⏳") + waitCtx, cancel := context.WithTimeout(ctx, time.Minute) + defer cancel() + if err := dockerRunner.WaitForReady(waitCtx); err != nil { + dockerRunner.Stop(keepFlag) + return fmt.Errorf("failed to wait for service readiness: %w", err) + } + + // run post hook operations + if err := svcManager.ExecutePostHookActions(ctx); err != nil { + dockerRunner.Stop(keepFlag) + return fmt.Errorf("failed to execute post-hook operations: %w", err) + } + + slog.Info("Running lifecycle hooks of services... ⏳") + if err := dockerRunner.RunLifecycleHooks(ctx); err != nil { + dockerRunner.Stop(keepFlag) + return fmt.Errorf("failed to run lifecycle hooks: %w", err) + } + if !interactive { log.Println() log.Println("All services started! ✅") @@ -789,20 +809,6 @@ func runIt(recipe playground.Recipe) error { log.Println() } - log.Println("Waiting for services to get healthy... ⏳") - waitCtx, cancel := context.WithTimeout(ctx, time.Minute) - defer cancel() - if err := dockerRunner.WaitForReady(waitCtx); err != nil { - dockerRunner.Stop(keepFlag) - return fmt.Errorf("failed to wait for service readiness: %w", err) - } - - // run post hook operations - if err := svcManager.ExecutePostHookActions(); err != nil { - dockerRunner.Stop(keepFlag) - return fmt.Errorf("failed to execute post-hook operations: %w", err) - } - slog.Info("All services are healthy! Ready to accept transactions. 🚀", "session-id", svcManager.ID) // get the output from the recipe diff --git a/playground/artifacts.go b/playground/artifacts.go index 3f078d1c..540839af 100644 --- a/playground/artifacts.go +++ b/playground/artifacts.go @@ -68,6 +68,9 @@ var opStateJovian []byte //go:embed config.yaml.tmpl var clConfigContent []byte +//go:embed utils/builderhub-config.yaml +var defaultBuilderHubConfig []byte + // l2ForkConfig holds the selected L2 fork configuration files type l2ForkConfig struct { genesis []byte // L2 genesis JSON @@ -249,6 +252,7 @@ func (b *ArtifactsBuilder) Build(out *output) error { genesisTime := time.Now().Add(time.Duration(b.genesisDelay) * time.Second) config := params.BeaconConfig() + config.ElectraForkEpoch = 0 gen := interop.GethTestnetGenesis(genesisTime, config) // HACK: fix this in prysm? diff --git a/playground/cmd_validate.go b/playground/cmd_validate.go index e64e1471..32aac4ac 100644 --- a/playground/cmd_validate.go +++ b/playground/cmd_validate.go @@ -95,6 +95,10 @@ func validateYAMLRecipe(recipe *YAMLRecipe, baseRecipes []Recipe, result *Valida if serviceConfig.Remove { result.AddWarning("removing service '%s' from component '%s' - verify names match base recipe", serviceName, componentName) } + + // Validate lifecycle cannot be used with host_path, release, or args + validateLifecycleConfig(serviceName, componentName, serviceConfig, result) + // Validate args and replace_args are mutually exclusive if len(serviceConfig.Args) > 0 && len(serviceConfig.ReplaceArgs) > 0 { result.AddError("service '%s' in component '%s': args and replace_args cannot be used together", serviceName, componentName) @@ -105,6 +109,36 @@ func validateYAMLRecipe(recipe *YAMLRecipe, baseRecipes []Recipe, result *Valida } } +// validateLifecycleConfig checks that lifecycle_hooks is not used with incompatible options +// and that init/start/stop are only used when lifecycle_hooks is true +func validateLifecycleConfig(serviceName, componentName string, config *YAMLServiceConfig, result *ValidationResult) { + hasLifecycleFields := len(config.Init) > 0 || config.Start != "" || len(config.Stop) > 0 + + // If lifecycle_hooks is not set but lifecycle fields are used, that's an error + if !config.LifecycleHooks { + if hasLifecycleFields { + result.AddError("service '%s' in component '%s': init, start, and stop require lifecycle_hooks: true", serviceName, componentName) + } + return + } + + // lifecycle_hooks is true - check for incompatible options + if config.HostPath != "" { + result.AddError("service '%s' in component '%s': lifecycle_hooks cannot be used with host_path", serviceName, componentName) + } + if config.Release != nil { + result.AddError("service '%s' in component '%s': lifecycle_hooks cannot be used with release", serviceName, componentName) + } + if len(config.Args) > 0 { + result.AddError("service '%s' in component '%s': lifecycle_hooks cannot be used with args", serviceName, componentName) + } + + // Validate that at least one of init or start is specified + if len(config.Init) == 0 && config.Start == "" { + result.AddError("service '%s' in component '%s': lifecycle_hooks requires at least one of init or start", serviceName, componentName) + } +} + func validateUniqueServiceNames(manifest *Manifest, result *ValidationResult) { seen := make(map[string]bool) for _, svc := range manifest.Services { diff --git a/playground/cmd_validate_test.go b/playground/cmd_validate_test.go new file mode 100644 index 00000000..7456d69d --- /dev/null +++ b/playground/cmd_validate_test.go @@ -0,0 +1,277 @@ +package playground + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidateLifecycleConfig(t *testing.T) { + tests := []struct { + name string + config *YAMLServiceConfig + expectedErrors int + errorContains []string + }{ + { + name: "valid lifecycle config with init start and stop", + config: &YAMLServiceConfig{ + LifecycleHooks: true, + Init: []string{"echo init"}, + Start: "./start.sh", + Stop: []string{"echo stop"}, + }, + expectedErrors: 0, + }, + { + name: "valid lifecycle config with only init", + config: &YAMLServiceConfig{ + LifecycleHooks: true, + Init: []string{"echo init"}, + }, + expectedErrors: 0, + }, + { + name: "valid lifecycle config with init and stop", + config: &YAMLServiceConfig{ + LifecycleHooks: true, + Init: []string{"echo init"}, + Stop: []string{"echo stop"}, + }, + expectedErrors: 0, + }, + { + name: "valid lifecycle config with only start", + config: &YAMLServiceConfig{ + LifecycleHooks: true, + Start: "./start.sh", + }, + expectedErrors: 0, + }, + { + name: "lifecycle_hooks with host_path", + config: &YAMLServiceConfig{ + LifecycleHooks: true, + HostPath: "/usr/bin/app", + Start: "./start.sh", + }, + expectedErrors: 1, + errorContains: []string{"lifecycle_hooks cannot be used with host_path"}, + }, + { + name: "lifecycle_hooks with release", + config: &YAMLServiceConfig{ + LifecycleHooks: true, + Release: &YAMLReleaseConfig{ + Name: "app", + Org: "org", + Version: "v1.0.0", + }, + Start: "./start.sh", + }, + expectedErrors: 1, + errorContains: []string{"lifecycle_hooks cannot be used with release"}, + }, + { + name: "lifecycle_hooks with args", + config: &YAMLServiceConfig{ + LifecycleHooks: true, + Args: []string{"--port", "8080"}, + Start: "./start.sh", + }, + expectedErrors: 1, + errorContains: []string{"lifecycle_hooks cannot be used with args"}, + }, + { + name: "lifecycle_hooks with only stop - invalid", + config: &YAMLServiceConfig{ + LifecycleHooks: true, + Stop: []string{"echo stop"}, + }, + expectedErrors: 1, + errorContains: []string{"lifecycle_hooks requires at least one of init or start"}, + }, + { + name: "lifecycle_hooks with nothing - invalid", + config: &YAMLServiceConfig{ + LifecycleHooks: true, + }, + expectedErrors: 1, + errorContains: []string{"lifecycle_hooks requires at least one of init or start"}, + }, + { + name: "lifecycle_hooks with all incompatible options", + config: &YAMLServiceConfig{ + LifecycleHooks: true, + HostPath: "/usr/bin/app", + Release: &YAMLReleaseConfig{ + Name: "app", + Org: "org", + Version: "v1.0.0", + }, + Args: []string{"--port", "8080"}, + Start: "./start.sh", + }, + expectedErrors: 3, + errorContains: []string{ + "lifecycle_hooks cannot be used with host_path", + "lifecycle_hooks cannot be used with release", + "lifecycle_hooks cannot be used with args", + }, + }, + { + name: "no lifecycle_hooks - no errors", + config: &YAMLServiceConfig{ + HostPath: "/usr/bin/app", + Args: []string{"--port", "8080"}, + }, + expectedErrors: 0, + }, + { + name: "init without lifecycle_hooks - invalid", + config: &YAMLServiceConfig{ + HostPath: "/usr/bin/app", + Init: []string{"echo init"}, + }, + expectedErrors: 1, + errorContains: []string{"init, start, and stop require lifecycle_hooks: true"}, + }, + { + name: "start without lifecycle_hooks - invalid", + config: &YAMLServiceConfig{ + HostPath: "/usr/bin/app", + Start: "./start.sh", + }, + expectedErrors: 1, + errorContains: []string{"init, start, and stop require lifecycle_hooks: true"}, + }, + { + name: "stop without lifecycle_hooks - invalid", + config: &YAMLServiceConfig{ + HostPath: "/usr/bin/app", + Stop: []string{"echo stop"}, + }, + expectedErrors: 1, + errorContains: []string{"init, start, and stop require lifecycle_hooks: true"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := &ValidationResult{} + validateLifecycleConfig("test-svc", "test-component", tt.config, result) + + require.Len(t, result.Errors, tt.expectedErrors) + for _, expected := range tt.errorContains { + found := false + for _, err := range result.Errors { + if strings.Contains(err, expected) { + found = true + break + } + } + require.True(t, found, "expected error containing '%s' not found in %v", expected, result.Errors) + } + }) + } +} + +func TestValidateLifecycleConfig_InYAMLRecipe(t *testing.T) { + // Test that lifecycle validation is called during YAML recipe validation + recipe := &YAMLRecipe{ + config: &YAMLRecipeConfig{ + Base: "l1", + Recipe: map[string]*YAMLComponentConfig{ + "test-component": { + Services: map[string]*YAMLServiceConfig{ + "test-svc": { + LifecycleHooks: true, + HostPath: "/usr/bin/app", + Start: "./start.sh", + }, + }, + }, + }, + }, + } + + baseRecipes := []Recipe{&L1Recipe{}} + result := &ValidationResult{} + validateYAMLRecipe(recipe, baseRecipes, result) + + require.NotEmpty(t, result.Errors) + found := false + for _, err := range result.Errors { + if strings.Contains(err, "lifecycle_hooks cannot be used with host_path") { + found = true + break + } + } + require.True(t, found, "expected lifecycle validation error not found") +} + +func TestValidateLifecycleConfig_InYAMLRecipe_WithoutLifecycleHooks(t *testing.T) { + // Test that init/start/stop without lifecycle_hooks is caught during YAML recipe validation + recipe := &YAMLRecipe{ + config: &YAMLRecipeConfig{ + Base: "l1", + Recipe: map[string]*YAMLComponentConfig{ + "test-component": { + Services: map[string]*YAMLServiceConfig{ + "test-svc": { + HostPath: "/usr/bin/app", + Start: "./start.sh", + }, + }, + }, + }, + }, + } + + baseRecipes := []Recipe{&L1Recipe{}} + result := &ValidationResult{} + validateYAMLRecipe(recipe, baseRecipes, result) + + require.NotEmpty(t, result.Errors) + found := false + for _, err := range result.Errors { + if strings.Contains(err, "init, start, and stop require lifecycle_hooks: true") { + found = true + break + } + } + require.True(t, found, "expected lifecycle validation error not found in %v", result.Errors) +} + +func TestValidateYAMLRecipe_ArgsAndReplaceArgsMutualExclusivity(t *testing.T) { + recipe := &YAMLRecipe{ + config: &YAMLRecipeConfig{ + Base: "l1", + Recipe: map[string]*YAMLComponentConfig{ + "test-component": { + Services: map[string]*YAMLServiceConfig{ + "test-svc": { + Args: []string{"--port", "8080"}, + ReplaceArgs: []string{"--host", "localhost"}, + }, + }, + }, + }, + }, + } + + baseRecipes := []Recipe{&L1Recipe{}} + result := &ValidationResult{} + validateYAMLRecipe(recipe, baseRecipes, result) + + require.NotEmpty(t, result.Errors) + found := false + for _, err := range result.Errors { + if strings.Contains(err, "args and replace_args cannot be used together") { + found = true + break + } + } + require.True(t, found, "expected mutual exclusivity error not found in %v", result.Errors) +} diff --git a/playground/components.go b/playground/components.go index c0b808f1..55fe3826 100644 --- a/playground/components.go +++ b/playground/components.go @@ -1,6 +1,7 @@ package playground import ( + "context" "fmt" "os" "strconv" @@ -13,6 +14,7 @@ import ( mevboostrelay "github.com/flashbots/builder-playground/mev-boost-relay" "github.com/flashbots/go-boost-utils/bls" "github.com/flashbots/go-boost-utils/utils" + "github.com/goccy/go-yaml" ) var ( @@ -922,7 +924,7 @@ type BuilderHub struct { BuilderConfig string } -func (b *BuilderHub) Apply(ctx *ExContext) *Component { +func (b *BuilderHub) Apply(exCtx *ExContext) *Component { component := NewComponent("builder-hub") // Database service @@ -942,8 +944,8 @@ func (b *BuilderHub) Apply(ctx *ExContext) *Component { StartPeriod: 2 * time.Second, }) - // API service - apiSrv := component.NewService("builder-hub-api"). + // API service + component.NewService("builder-hub-api"). WithImage("docker.io/flashbots/builder-hub"). WithTag("0.3.1-alpha1"). DependsOnHealthy("builder-hub-db"). @@ -965,15 +967,14 @@ func (b *BuilderHub) Apply(ctx *ExContext) *Component { Timeout: 30 * time.Second, Retries: 3, StartPeriod: 1 * time.Second, + }). + WithPostHook(&postHook{ + Name: "register-builder", + Action: func(ctx context.Context, m *Manifest, s *Service) error { + return registerBuilderHook(ctx, exCtx, m, s, b) + }, }) - apiSrv.WithPostHook(&postHook{ - Name: "register-builder", - Action: func(s *Service) error { - return registerBuilderHook(ctx, s, b) - }, - }) - // Proxy service component.NewService("builder-hub-proxy"). WithImage("docker.io/flashbots/builder-hub-mock-proxy"). @@ -992,14 +993,33 @@ func (b *BuilderHub) Apply(ctx *ExContext) *Component { return component } -func registerBuilderHook(ctx *ExContext, s *Service, b *BuilderHub) error { - genesis, err := ctx.Output.Read("genesis.json") +type builderHubConfig struct { + Playground struct { + BuilderHubConfig struct { + BuilderID string `yaml:"builder_id"` + BuilderIP string `yaml:"builder_ip"` + MeasurementID string `yaml:"measurement_id"` + Network string `yaml:"network"` + } `yaml:"builder_hub_config"` + } `yaml:"playground"` +} + +func registerBuilderHook(ctx context.Context, exCtx *ExContext, manifest *Manifest, s *Service, b *BuilderHub) error { + genesis, err := exCtx.Output.Read("genesis.json") if err != nil { return err } - configYaml, err := os.ReadFile(b.BuilderConfig) - if err != nil { + configYaml := defaultBuilderHubConfig + if len(b.BuilderConfig) > 0 { + configYaml, err = os.ReadFile(b.BuilderConfig) + if err != nil { + return err + } + } + + var config builderHubConfig + if err := yaml.Unmarshal([]byte(configYaml), &config); err != nil { return err } @@ -1016,15 +1036,17 @@ func registerBuilderHook(ctx *ExContext, s *Service, b *BuilderHub) error { return err } - endpoint := fmt.Sprintf("http://localhost:%d", s.MustGetPort("admin").HostPort) input := &builderHubRegisterBuilderInput{ - BuilderID: "builder", - BuilderIP: b.BuilderIP, - MeasurementID: "test", - Network: "playground", + BuilderID: config.Playground.BuilderHubConfig.BuilderID, + BuilderIP: config.Playground.BuilderHubConfig.BuilderIP, + MeasurementID: config.Playground.BuilderHubConfig.MeasurementID, + Network: config.Playground.BuilderHubConfig.Network, Config: string(configJSON), } - if err := registerBuilder(endpoint, input); err != nil { + adminApi := fmt.Sprintf("http://localhost:%d", manifest.MustGetService("builder-hub-api").MustGetPort("admin").HostPort) + beaconApi := fmt.Sprintf("http://localhost:%d", manifest.MustGetService("beacon").MustGetPort("http").HostPort) + rethApi := fmt.Sprintf("http://localhost:%d", manifest.MustGetService("el").MustGetPort("http").HostPort) + if err := registerBuilder(ctx, adminApi, beaconApi, rethApi, input); err != nil { return err } return nil @@ -1052,3 +1074,25 @@ func UseHealthmon(component *Component, s *Service, chain string) { StartPeriod: 1 * time.Second, }) } + +// Fileserver serves genesis and testnet files over HTTP using Caddy. +// This allows VMs or external clients to fetch configuration files. +type Fileserver struct{} + +func (f *Fileserver) Apply(ctx *ExContext) *Component { + component := NewComponent("fileserver") + + component.NewService("server"). + WithImage("caddy"). + WithTag("2-alpine"). + WithArgs( + "caddy", "file-server", + "--root", "/data", + "--listen", `:{{Port "http" 8100}}`, + "--browse", + ). + WithArtifact("/data/genesis.json", "genesis.json"). + WithArtifact("/data/testnet", "testnet") + + return component +} diff --git a/playground/components_test.go b/playground/components_test.go index c70b132c..53be815f 100644 --- a/playground/components_test.go +++ b/playground/components_test.go @@ -134,14 +134,19 @@ func TestComponentRbuilder(t *testing.T) { tt.WaitForBlock("el", 1) } -func TestRecipeBuilderHub(t *testing.T) { +func TestRecipeBuilderNet(t *testing.T) { tt := newTestFramework(t) defer tt.Close() - tt.test(&BuilderHub{}, nil) + recipe := &BuilderNetRecipe{} + manifest := tt.test(recipe, []string{}) + output := recipe.Output(manifest) + + httpEndpoint := fmt.Sprintf("http://localhost:%d", manifest.MustGetService("builder-hub-api").MustGetPort("http").Port) - // TODO: Calling the port directly on the host machine will not work once we have multiple - // tests running in parallel + proxy := output["builder-hub-proxy"].(string) + admin := output["builder-hub-admin"].(string) + internal := output["builder-hub-internal"].(string) // Set measurements from the admin API. buf := bytes.NewBuffer([]byte(` @@ -151,7 +156,7 @@ func TestRecipeBuilderHub(t *testing.T) { "measurements": {} } `)) - resp, err := http.Post("http://localhost:8081/api/admin/v1/measurements", "application/json", buf) + resp, err := http.Post(admin+"/api/admin/v1/measurements", "application/json", buf) require.NoError(t, err) defer resp.Body.Close() @@ -160,7 +165,7 @@ func TestRecipeBuilderHub(t *testing.T) { "enabled": true } `)) - resp, err = http.Post("http://localhost:8081/api/admin/v1/measurements/activation/test1", "application/json", buf) + resp, err = http.Post(admin+"/api/admin/v1/measurements/activation/test1", "application/json", buf) require.NoError(t, err) defer resp.Body.Close() @@ -169,10 +174,10 @@ func TestRecipeBuilderHub(t *testing.T) { } // Verify from all APIs that measurements are in place. - ports := []string{"8080", "8082", "8888"} - for _, port := range ports { + endpoints := []string{httpEndpoint, internal, proxy} + for _, endpoint := range endpoints { var m measurementList - resp, err = http.Get("http://localhost:" + port + "/api/l1-builder/v1/measurements") + resp, err = http.Get(endpoint + "/api/l1-builder/v1/measurements") require.NoError(t, err) defer resp.Body.Close() require.NoError(t, json.NewDecoder(resp.Body).Decode(&m)) @@ -183,32 +188,6 @@ func TestRecipeBuilderHub(t *testing.T) { require.Equal(t, resp.StatusCode, http.StatusOK) } -func TestRecipeBuilderHub_RegisterBuilder(t *testing.T) { - tt := newTestFramework(t) - defer tt.Close() - - manifest := tt.test(&BuilderHub{}, nil) - - apiPort := manifest.MustGetService("builder-hub-api").MustGetPort("admin") - endpoint := fmt.Sprintf("http://localhost:%d", apiPort.HostPort) - - err := registerBuilder(endpoint, &builderHubRegisterBuilderInput{ - BuilderID: "test_builder", - BuilderIP: "1.2.3.4", - MeasurementID: "test", - Network: "playground", - Config: "{}", - }) - require.NoError(t, err) -} - -func TestRecipeBuilderNet(t *testing.T) { - tt := newTestFramework(t) - defer tt.Close() - - tt.test(&BuilderNetRecipe{}, []string{}) -} - type testFramework struct { t *testing.T runner *LocalRunner @@ -239,6 +218,11 @@ func (tt *testFramework) test(component ComponentGen, args []string) *Manifest { if err := os.MkdirAll(e2eTestDir, 0o755); err != nil { t.Fatal(err) } + // Convert to absolute path to ensure it works from any working directory + e2eTestDir, err := filepath.Abs(e2eTestDir) + if err != nil { + t.Fatal(err) + } homeDir, err := utils.GetPlaygroundDir() require.NoError(t, err) diff --git a/playground/custom_recipes.go b/playground/custom_recipes.go index 937706d9..b4911a54 100644 --- a/playground/custom_recipes.go +++ b/playground/custom_recipes.go @@ -232,7 +232,8 @@ func GenerateCustomRecipeToDir(customRecipeName, targetDir string) (string, erro // Write the file to target directory (playground.yaml is already correctly named) fullPath := filepath.Join(targetDir, fileName) - if err := os.WriteFile(fullPath, content, 0o644); err != nil { + + if err := os.WriteFile(fullPath, content, 0o755); err != nil { return fmt.Errorf("failed to write %s: %w", fullPath, err) } return nil diff --git a/playground/local_runner.go b/playground/local_runner.go index a91f8101..c0f46c7d 100644 --- a/playground/local_runner.go +++ b/playground/local_runner.go @@ -57,7 +57,12 @@ type LocalRunner struct { // handles stores the references to the processes that are running on host machine // they are executed sequentially so we do not need to lock the handles - handles []*exec.Cmd + handles []*exec.Cmd + handlesMu sync.Mutex + + // lifecycleServices tracks services with lifecycle configs for stop command execution + lifecycleServices []*lifecycleServiceInfo + lifecycleMu sync.Mutex // exitError signals when one of the services fails exitErr chan error @@ -128,10 +133,14 @@ func NewLocalRunner(cfg *RunnerConfig) (*LocalRunner, error) { if service.HostPath != "" { continue } + // If LifecycleHooks is set, no binary path needed - commands are shell commands + if service.LifecycleHooks { + continue + } // Otherwise, download the release artifact releaseArtifact := service.release if releaseArtifact == nil { - return nil, fmt.Errorf("service '%s' requires either host_path or release configuration", service.Name) + return nil, fmt.Errorf("service '%s' requires either host_path, release, or lifecycle configuration", service.Name) } bin, err := DownloadRelease(cfg.Out.homeDir, releaseArtifact) if err != nil { @@ -182,8 +191,8 @@ func NewLocalRunner(cfg *RunnerConfig) (*LocalRunner, error) { func (d *LocalRunner) checkAndUpdateReadiness() { for name, task := range d.tasks { - // ensure the task is not a host service - if d.isHostService(name) { + // ensure the task is a docker service + if !d.isDockerService(name) { continue } @@ -278,10 +287,16 @@ func (d *LocalRunner) Stop(keepResources bool) error { // Possible to make a more graceful exit with os.Interrupt here // but preferring a quick exit for now. d.stopAllProcessesWithSignal(os.Kill) + + // Run lifecycle stop commands for all tracked lifecycle services + d.runAllLifecycleStopCommands() + return StopSession(d.manifest.ID, keepResources) } func (d *LocalRunner) stopAllProcessesWithSignal(signal os.Signal) { + d.handlesMu.Lock() + defer d.handlesMu.Unlock() for _, handle := range d.handles { stopProcessWithSignal(handle, signal) } @@ -712,6 +727,13 @@ func (d *LocalRunner) isHostService(name string) bool { return d.manifest.MustGetService(name).HostPath != "" } +// isDockerService returns true if the service should run in Docker. +// Services with HostPath or LifecycleHooks run on the host, not in Docker. +func (d *LocalRunner) isDockerService(name string) bool { + svc := d.manifest.MustGetService(name) + return svc.HostPath == "" && !svc.LifecycleHooks +} + func (d *LocalRunner) generateDockerCompose() ([]byte, error) { compose := map[string]interface{}{ // We create a new network to be used by all the services so that @@ -736,8 +758,8 @@ func (d *LocalRunner) generateDockerCompose() ([]byte, error) { volumes := map[string]struct{}{} for _, svc := range d.manifest.Services { - if d.isHostService(svc.Name) { - // skip services that are going to be launched on host + if !d.isDockerService(svc.Name) { + // skip services that run on host (HostPath or LifecycleHooks) continue } var ( @@ -871,7 +893,12 @@ func (d *LocalRunner) waitForDependencies(ss *Service) error { } // runOnHost runs the service on the host machine -func (d *LocalRunner) runOnHost(ss *Service) error { +func (d *LocalRunner) runOnHost(ctx context.Context, ss *Service) error { + // If this service has lifecycle hooks, start with them + if ss.LifecycleHooks { + return d.startWithLifecycleHooks(ctx, ss) + } + // Wait for dependencies to be healthy before starting if err := d.waitForDependencies(ss); err != nil { return fmt.Errorf("failed waiting for dependencies: %w", err) @@ -948,7 +975,8 @@ func (d *LocalRunner) runOnHost(ss *Service) error { } }() - // we do not need to lock this array because we run the host services sequentially + d.handlesMu.Lock() + defer d.handlesMu.Unlock() d.handles = append(d.handles, cmd) return nil } @@ -1118,8 +1146,8 @@ func (d *LocalRunner) pullNotAvailableImages(ctx context.Context) error { g, ctx := errgroup.WithContext(ctx) for _, svc := range d.manifest.Services { - if d.isHostService(svc.Name) { - continue // Skip host services + if !d.isDockerService(svc.Name) { + continue // Skip non-docker services (HostPath or LifecycleHooks) } s := svc // Capture loop variable @@ -1154,7 +1182,7 @@ func (d *LocalRunner) Run(ctx context.Context) error { defer utils.StartTimer("docker.up")() - g := new(errgroup.Group) + g, ctx := errgroup.WithContext(ctx) // First start the services that are running in docker-compose g.Go(func() error { @@ -1185,7 +1213,7 @@ func (d *LocalRunner) Run(ctx context.Context) error { if d.isHostService(svc.Name) { svc := svc g.Go(func() error { - if err := d.runOnHost(svc); err != nil { + if err := d.runOnHost(ctx, svc); err != nil { return fmt.Errorf("failed to run host service: %v", err) } return nil @@ -1196,6 +1224,20 @@ func (d *LocalRunner) Run(ctx context.Context) error { return g.Wait() } +func (d *LocalRunner) RunLifecycleHooks(ctx context.Context) error { + g := new(errgroup.Group) + for _, svc := range d.manifest.Services { + if !svc.LifecycleHooks { + continue + } + svc := svc // capture loop variable + g.Go(func() error { + return d.runOnHost(ctx, svc) + }) + } + return g.Wait() +} + type HealthCheckResponse struct { Output string ExitCode int diff --git a/playground/local_runner_lifecycle.go b/playground/local_runner_lifecycle.go new file mode 100644 index 00000000..9bc3d99c --- /dev/null +++ b/playground/local_runner_lifecycle.go @@ -0,0 +1,162 @@ +package playground + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + + "github.com/flashbots/builder-playground/utils/mainctx" +) + +// lifecycleContext holds shared state for lifecycle command execution +type lifecycleContext struct { + svc *Service + dir string + logWriter io.Writer + logPath string +} + +// lifecycleServiceInfo tracks a lifecycle service with its log file for stop commands +type lifecycleServiceInfo struct { + svc *Service + logFile io.Writer + logPath string +} + +func (lc *lifecycleContext) newCmd(ctx context.Context, command string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "sh", "-c", command) + cmd.Dir = lc.dir + cmd.Stdout = lc.logWriter + cmd.Stderr = lc.logWriter + return cmd +} + +func (lc *lifecycleContext) logHeader(phase string, index int, command string) { + if lc.logWriter == nil { + return + } + if index >= 0 { + fmt.Fprintf(lc.logWriter, "=== %s command %d: %s ===\n", phase, index, command) + } else { + fmt.Fprintf(lc.logWriter, "=== %s command: %s ===\n", phase, command) + } +} + +func (lc *lifecycleContext) formatError(phase, command string, err error) string { + errMsg := fmt.Sprintf("service %s %s command failed:\n Command: %s\n Log file: %s\n Exit error: %v", + lc.svc.Name, phase, command, lc.logPath, err) + if lastLines := readLastLines(lc.logPath, 10); lastLines != "" { + errMsg += fmt.Sprintf("\n Last output:\n%s", lastLines) + } + return errMsg +} + +// startWithLifecycleHooks runs a service with lifecycle commands (init, start) +func (d *LocalRunner) startWithLifecycleHooks(ctx context.Context, svc *Service) error { + if err := d.waitForDependencies(svc); err != nil { + return fmt.Errorf("failed waiting for dependencies: %w", err) + } + + logFile, err := d.out.LogOutput(svc.Name) + var logPath string + if err != nil { + logFile = os.Stdout + logPath = "" // Don't try to read stdout as a file + } else { + logPath = logFile.Name() + } + + // Use recipe directory for lifecycle hooks if set, otherwise use artifacts dir + dir := d.out.dst + if svc.RecipeDir != "" { + dir = svc.RecipeDir + } + + lc := &lifecycleContext{ + svc: svc, + dir: dir, + logWriter: logFile, + logPath: logPath, + } + + d.lifecycleMu.Lock() + d.lifecycleServices = append(d.lifecycleServices, &lifecycleServiceInfo{ + svc: svc, + logFile: logFile, + logPath: logPath, + }) + d.lifecycleMu.Unlock() + + // Run init commands sequentially - each must return exit code 0 + for i, cmd := range svc.Init { + slog.Info("Running lifecycle init command", "service", svc.Name, "command", cmd, "index", i) + lc.logHeader("Init", i, cmd) + + if err := lc.newCmd(ctx, cmd).Run(); err != nil { + return fmt.Errorf("%s", lc.formatError("init", cmd, err)) + } + } + + if svc.Start == "" { + return nil + } + + // Run start command - may hang (long-running) or return 0 + slog.Info("Running lifecycle start command", "service", svc.Name, "command", svc.Start) + lc.logHeader("Start", -1, svc.Start) + + startCmd := lc.newCmd(ctx, svc.Start) + go func() { + if err := startCmd.Run(); err != nil { + if mainctx.IsExiting() { + return + } + slog.Error("Lifecycle service failed", "service", svc.Name, "error", err) + d.sendExitError(fmt.Errorf("%s", lc.formatError("start", svc.Start, err))) + } + }() + + d.handlesMu.Lock() + defer d.handlesMu.Unlock() + d.handles = append(d.handles, startCmd) + return nil +} + +// runLifecycleStopCommands runs the stop commands for a lifecycle service +func (d *LocalRunner) runLifecycleStopCommands(svc *Service, logOutput io.Writer, logPath string) { + if len(svc.Stop) == 0 { + return + } + + // Use recipe directory for lifecycle hooks if set, otherwise use artifacts dir + dir := d.out.dst + if svc.RecipeDir != "" { + dir = svc.RecipeDir + } + + lc := &lifecycleContext{ + svc: svc, + dir: dir, + logWriter: logOutput, + logPath: logPath, + } + + for i, stopCmd := range svc.Stop { + slog.Info("Running lifecycle stop command", "service", svc.Name, "command", stopCmd, "index", i) + lc.logHeader("Stop", i, stopCmd) + + if err := lc.newCmd(context.Background(), stopCmd).Run(); err != nil { + slog.Warn("Lifecycle stop command failed (continuing)", "service", svc.Name, "command", stopCmd, "error", err) + } + } +} + +// runAllLifecycleStopCommands runs stop commands for all lifecycle services +func (d *LocalRunner) runAllLifecycleStopCommands() { + for _, info := range d.lifecycleServices { + d.runLifecycleStopCommands(info.svc, info.logFile, info.logPath) + } +} diff --git a/playground/local_runner_lifecycle_test.go b/playground/local_runner_lifecycle_test.go new file mode 100644 index 00000000..3c3f4baa --- /dev/null +++ b/playground/local_runner_lifecycle_test.go @@ -0,0 +1,211 @@ +package playground + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestLocalRunner_LifecycleService_InitCommands(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "lifecycle-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + out := &output{dst: tmpDir} + + // Create a minimal LocalRunner - no Docker client needed for lifecycle + runner := &LocalRunner{ + out: out, + lifecycleServices: []*lifecycleServiceInfo{}, + } + + // Create a service with init commands that create files + testFile := filepath.Join(tmpDir, "init-test.txt") + svc := &Service{ + Name: "test-lifecycle", + LifecycleHooks: true, + Init: []string{ + "echo 'init1' > " + testFile, + "echo 'init2' >> " + testFile, + }, + } + + err = runner.startWithLifecycleHooks(context.Background(), svc) + require.NoError(t, err) + + // Verify init commands ran + content, err := os.ReadFile(testFile) + require.NoError(t, err) + require.Contains(t, string(content), "init1") + require.Contains(t, string(content), "init2") + + // Verify service was tracked for stop commands + require.Len(t, runner.lifecycleServices, 1) + require.Equal(t, "test-lifecycle", runner.lifecycleServices[0].svc.Name) +} + +func TestLocalRunner_LifecycleService_InitFailure(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "lifecycle-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + out := &output{dst: tmpDir} + + runner := &LocalRunner{ + out: out, + lifecycleServices: []*lifecycleServiceInfo{}, + } + + svc := &Service{ + Name: "test-lifecycle", + LifecycleHooks: true, + Init: []string{ + "exit 1", // This will fail + }, + } + + err = runner.startWithLifecycleHooks(context.Background(), svc) + require.Error(t, err) + require.Contains(t, err.Error(), "init command failed") +} + +func TestLocalRunner_LifecycleService_StartCommand(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "lifecycle-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + out := &output{dst: tmpDir} + + runner := &LocalRunner{ + out: out, + handles: []*exec.Cmd{}, + lifecycleServices: []*lifecycleServiceInfo{}, + } + + startFile := filepath.Join(tmpDir, "start-ran.txt") + svc := &Service{ + Name: "test-lifecycle", + LifecycleHooks: true, + Start: "echo 'started' > " + startFile, + } + + err = runner.startWithLifecycleHooks(context.Background(), svc) + require.NoError(t, err) + + // Give the goroutine time to run + require.Eventually(t, func() bool { + _, err := os.Stat(startFile) + return err == nil + }, 2*time.Second, 100*time.Millisecond) + + content, err := os.ReadFile(startFile) + require.NoError(t, err) + require.Contains(t, string(content), "started") + + // Verify handle was tracked + require.Len(t, runner.handles, 1) +} + +func TestLocalRunner_LifecycleService_InitOnly(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "lifecycle-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + out := &output{dst: tmpDir} + + runner := &LocalRunner{ + out: out, + handles: []*exec.Cmd{}, + lifecycleServices: []*lifecycleServiceInfo{}, + } + + initFile := filepath.Join(tmpDir, "init-only.txt") + svc := &Service{ + Name: "test-lifecycle", + LifecycleHooks: true, + Init: []string{ + "echo 'init-only' > " + initFile, + }, + } + + err = runner.startWithLifecycleHooks(context.Background(), svc) + require.NoError(t, err) + + // Verify init ran + content, err := os.ReadFile(initFile) + require.NoError(t, err) + require.Contains(t, string(content), "init-only") + + // No start command, so no handle should be tracked + require.Len(t, runner.handles, 0) +} + +func TestLocalRunner_StopCommands(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "lifecycle-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + out := &output{dst: tmpDir} + + stopFile := filepath.Join(tmpDir, "stop.txt") + svc := &Service{ + Name: "test-lifecycle", + LifecycleHooks: true, + Init: []string{"echo init"}, + Stop: []string{ + "echo 'stop1' > " + stopFile, + "echo 'stop2' >> " + stopFile, + }, + } + + runner := &LocalRunner{ + out: out, + lifecycleServices: []*lifecycleServiceInfo{{svc: svc}}, + } + + // Run all stop commands + runner.runAllLifecycleStopCommands() + + // Verify stop commands ran + content, err := os.ReadFile(stopFile) + require.NoError(t, err) + require.Contains(t, string(content), "stop1") + require.Contains(t, string(content), "stop2") +} + +func TestLocalRunner_StopCommands_ContinueOnError(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "lifecycle-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + out := &output{dst: tmpDir} + + stopFile := filepath.Join(tmpDir, "stop.txt") + svc := &Service{ + Name: "test-lifecycle", + LifecycleHooks: true, + Init: []string{"echo init"}, + Stop: []string{ + "exit 1", // This fails + "echo 'continued' > " + stopFile, // But this should still run + }, + } + + runner := &LocalRunner{ + out: out, + lifecycleServices: []*lifecycleServiceInfo{{svc: svc}}, + } + + // Run all stop commands - should not panic or stop on error + runner.runAllLifecycleStopCommands() + + // Verify second stop command still ran despite first failing + content, err := os.ReadFile(stopFile) + require.NoError(t, err) + require.Contains(t, string(content), "continued") +} diff --git a/playground/manifest.go b/playground/manifest.go index 49de52d6..d0997210 100644 --- a/playground/manifest.go +++ b/playground/manifest.go @@ -1,6 +1,7 @@ package playground import ( + "context" "encoding/json" "fmt" "log/slog" @@ -72,6 +73,34 @@ func (p *Component) AddService(ctx *ExContext, srv ComponentGen) { p.Inner = append(p.Inner, srv.Apply(ctx)) } +// FindService finds a service by name in the component tree +func (p *Component) FindService(name string) *Service { + for _, svc := range p.Services { + if svc.Name == name { + return svc + } + } + for _, inner := range p.Inner { + if found := inner.FindService(name); found != nil { + return found + } + } + return nil +} + +// RemoveService removes a service by name from the component tree +func (p *Component) RemoveService(name string) { + for i, svc := range p.Services { + if svc.Name == name { + p.Services = append(p.Services[:i], p.Services[i+1:]...) + return + } + } + for _, inner := range p.Inner { + inner.RemoveService(name) + } +} + func componentToManifest(p *Component) []*Service { services := p.Services @@ -378,6 +407,17 @@ type Service struct { UngracefulShutdown bool `json:"ungraceful_shutdown,omitempty"` + // LifecycleHooks enables lifecycle mode for host execution + LifecycleHooks bool `json:"lifecycle_hooks,omitempty"` + // Init commands run sequentially before start. Each must return exit code 0. + Init []string `json:"init,omitempty"` + // Start command runs the service (for lifecycle hooks). May hang or return 0. + Start string `json:"start,omitempty"` + // Stop commands run when playground exits. May return non-zero (best effort). + Stop []string `json:"stop,omitempty"` + // RecipeDir is the directory containing the recipe file (for lifecycle hooks) + RecipeDir string `json:"recipe_dir,omitempty"` + postHook *postHook release *release } @@ -509,6 +549,18 @@ func (s *Service) WithArgs(args ...string) *Service { return s } +// ReplaceArgs replaces argument values in the service's Args. +// The replacements map contains flag -> new_value pairs. +// For each flag found in Args, the following value is replaced. +func (s *Service) ReplaceArgs(replacements map[string]string) *Service { + for i := 0; i < len(s.Args); i++ { + if newValue, ok := replacements[s.Args[i]]; ok && i+1 < len(s.Args) { + s.Args[i+1] = newValue + } + } + return s +} + func (s *Service) WithVolume(name, localPath string, isLocalTri ...bool) *Service { isLocal := false if len(isLocalTri) == 1 { @@ -539,7 +591,7 @@ func (s *Service) WithReady(check ReadyCheck) *Service { type postHook struct { Name string - Action func(s *Service) error + Action func(ctx context.Context, m *Manifest, s *Service) error } func (s *Service) WithPostHook(hook *postHook) *Service { @@ -547,11 +599,11 @@ func (s *Service) WithPostHook(hook *postHook) *Service { return s } -func (m *Manifest) ExecutePostHookActions() error { +func (m *Manifest) ExecutePostHookActions(ctx context.Context) error { for _, svc := range m.Services { if svc.postHook != nil { slog.Info("Executing post-hook operation", "name", svc.postHook.Name) - if err := svc.postHook.Action(svc); err != nil { + if err := svc.postHook.Action(ctx, m, svc); err != nil { return err } } @@ -574,6 +626,11 @@ func (s *Service) WithUngracefulShutdown() *Service { return s } +func (s *Service) DependsOnNone() *Service { + s.DependsOn = nil + return s +} + func (s *Service) DependsOnHealthy(name string) *Service { s.DependsOn = append(s.DependsOn, &DependsOn{Name: name, Condition: DependsOnConditionHealthy}) return s diff --git a/playground/manifest_test.go b/playground/manifest_test.go index 52d37055..b376c8c5 100644 --- a/playground/manifest_test.go +++ b/playground/manifest_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNodeRefString(t *testing.T) { @@ -84,3 +85,75 @@ func TestManifestWriteRead(t *testing.T) { assert.Equal(t, svc.VolumesMapped, svc2.VolumesMapped) } } + +func TestComponent_RemoveService(t *testing.T) { + root := &Component{ + Name: "root", + Services: []*Service{ + {Name: "svc1"}, + {Name: "svc2"}, + {Name: "svc3"}, + }, + } + + root.RemoveService("svc2") + + require.Len(t, root.Services, 2) + for _, s := range root.Services { + require.NotEqual(t, "svc2", s.Name) + } +} + +func TestComponent_RemoveService_Nested(t *testing.T) { + root := &Component{ + Name: "root", + Inner: []*Component{ + { + Name: "child", + Services: []*Service{ + {Name: "nested-svc1"}, + {Name: "nested-svc2"}, + }, + }, + }, + } + + root.RemoveService("nested-svc1") + + require.Len(t, root.Inner[0].Services, 1) + require.Equal(t, "nested-svc2", root.Inner[0].Services[0].Name) +} + +func TestComponent_FindService(t *testing.T) { + root := &Component{ + Name: "root", + Services: []*Service{{Name: "root-svc"}}, + Inner: []*Component{ + { + Name: "child", + Services: []*Service{{Name: "child-svc"}}, + }, + }, + } + + tests := []struct { + name string + search string + expected bool + }{ + {"find root service", "root-svc", true}, + {"find child service", "child-svc", true}, + {"not found", "nonexistent", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := root.FindService(tt.search) + if tt.expected { + require.NotNil(t, result) + } else { + require.Nil(t, result) + } + }) + } +} diff --git a/playground/recipe_buildernet.go b/playground/recipe_buildernet.go index 3c708172..f125ed25 100644 --- a/playground/recipe_buildernet.go +++ b/playground/recipe_buildernet.go @@ -2,15 +2,21 @@ package playground import ( "bytes" + "context" "encoding/json" "fmt" "io" + "log/slog" "net/http" + "regexp" + "strings" "github.com/goccy/go-yaml" flag "github.com/spf13/pflag" ) +const BuilderHostIPAddress = "10.0.2.2" + var _ Recipe = &BuilderNetRecipe{} // BuilderNetRecipe is a recipe that extends the L1 recipe to include builder-hub @@ -53,6 +59,24 @@ func (b *BuilderNetRecipe) Apply(ctx *ExContext) *Component { BuilderConfig: b.builderConfig, }) + component.AddComponent(ctx, &Fileserver{}) + + // Apply beacon service overrides for buildernet. + // We need these for letting the builder connect to the beacon node. + // Basically, the beacon node can never be healthy until the builder + // connects. + if beacon := component.FindService("beacon"); beacon != nil { + beacon.ReplaceArgs(map[string]string{ + "--target-peers": "1", + }) + beacon.WithArgs("--subscribe-all-subnets") + } + if mevBoostRelay := component.FindService("mev-boost-relay"); mevBoostRelay != nil { + mevBoostRelay.DependsOnNone() + } + // Remove beacon healthmon - doesn't work with --target-peers=1 which is required for builder VM + component.RemoveService("beacon_healthmon") + component.RunContenderIfEnabled(ctx) return component @@ -77,38 +101,38 @@ func (b *BuilderNetRecipe) Output(manifest *Manifest) map[string]interface{} { return output } -func postRequest(endpoint, path string, input interface{}) error { +func postRequest(endpoint, path string, input interface{}) ([]byte, error) { var data []byte if dataBytes, ok := input.([]byte); ok { data = dataBytes } else if dataMap, ok := input.(map[string]interface{}); ok { dataBytes, err := json.Marshal(dataMap) if err != nil { - return err + return nil, err } data = dataBytes } else if dataStr, ok := input.(string); ok { data = []byte(dataStr) } else { - return fmt.Errorf("input type not expected") + return nil, fmt.Errorf("input type not expected") } fullEndpoint := endpoint + path resp, err := http.Post(fullEndpoint, "application/json", bytes.NewReader(data)) if err != nil { - return fmt.Errorf("failed to request endpoint '%s': %v", fullEndpoint, err) + return nil, fmt.Errorf("failed to request endpoint '%s': %v", fullEndpoint, err) } defer resp.Body.Close() dataResp, err := io.ReadAll(resp.Body) if err != nil { - return err + return dataResp, err } if resp.StatusCode != http.StatusOK { - return fmt.Errorf("incorrect status code %s: %s", resp.Status, string(dataResp)) + return nil, fmt.Errorf("incorrect status code %s: %s", resp.Status, string(dataResp)) } - return nil + return dataResp, nil } type builderHubRegisterBuilderInput struct { @@ -119,8 +143,63 @@ type builderHubRegisterBuilderInput struct { Config string } -func registerBuilder(httpEndpoint string, input *builderHubRegisterBuilderInput) error { - httpEndpoint = httpEndpoint + "/api/admin/v1" +type identityResponse struct { + Data struct { + PeerID string `json:"peer_id"` + } `json:"data"` +} + +type enodeResponse struct { + Result struct { + Enode string `json:"enode"` + } `json:"result"` + Error struct { + Code int `json:"code"` + Message string `json:"message"` + } +} + +func registerBuilder(ctx context.Context, builderAdminApi, beaconApi, rethApi string, input *builderHubRegisterBuilderInput) error { + builderAdminApi = builderAdminApi + "/api/admin/v1" + beaconApi = beaconApi + "/eth/v1/node/identity" + + resp, err := http.Get(beaconApi) + if err != nil { + return fmt.Errorf("failed to get beacon node identity: %v", err) + } + defer resp.Body.Close() + + var identityRespPayload identityResponse + if err := json.NewDecoder(resp.Body).Decode(&identityRespPayload); err != nil { + return fmt.Errorf("failed to decode identity resp payload: %v", err) + } + peerID := identityRespPayload.Data.PeerID + libP2PAddr := fmt.Sprintf("/ip4/%s/tcp/9001/p2p/%s", BuilderHostIPAddress, peerID) + slog.Info("setting builder config var", "libp2p-addr", libP2PAddr) + + respData, err := postRequest(rethApi, "/", map[string]interface{}{ + "jsonrpc": "2.0", + "method": "admin_nodeInfo", + "id": 1, + }) + if err != nil { + return fmt.Errorf("failed to get reth node info: %v", err) + } + var enodeRespPayload enodeResponse + if err := json.Unmarshal(respData, &enodeRespPayload); err != nil { + return fmt.Errorf("failed to decode enode resp payload: %v", err) + } + if enodeRespPayload.Error.Code != 0 { + return fmt.Errorf("error from reth admin_nodeInfo: %s", enodeRespPayload.Error.Message) + } + + // Replace the ip addr with the host ip address known in the builder vm + bootNode := replaceEnodeIP(enodeRespPayload.Result.Enode, BuilderHostIPAddress) + slog.Info("setting builder config var", "bootnode", bootNode) + + // Replace template vars. + input.Config = strings.ReplaceAll(input.Config, "{{EL_BOOTNODE}}", bootNode) + input.Config = strings.ReplaceAll(input.Config, "{{CL_LIBP2P_ADDR}}", libP2PAddr) // Validate input.Config, it must be a valid json file var configMap map[string]interface{} @@ -129,50 +208,61 @@ func registerBuilder(httpEndpoint string, input *builderHubRegisterBuilderInput) } // Create Allow-All Measurements - err := postRequest(httpEndpoint, "/measurements", map[string]interface{}{ + _, err = postRequest(builderAdminApi, "/measurements", map[string]interface{}{ "measurement_id": input.MeasurementID, "attestation_type": "test", "measurements": map[string]interface{}{}, }) if err != nil { - return err + return fmt.Errorf("failed to create measurements: %v", err) } // Enable Measurements - err = postRequest(httpEndpoint, fmt.Sprintf("/measurements/activation/%s", input.MeasurementID), map[string]interface{}{ + _, err = postRequest(builderAdminApi, fmt.Sprintf("/measurements/activation/%s", input.MeasurementID), map[string]interface{}{ "enabled": true, }) if err != nil { - return err + return fmt.Errorf("failed to activate measurements: %v", err) } // create the builder - err = postRequest(httpEndpoint, "/builders", map[string]interface{}{ + _, err = postRequest(builderAdminApi, "/builders", map[string]interface{}{ "name": input.BuilderID, "ip_address": input.BuilderIP, "network": input.Network, }) if err != nil { - return err + return fmt.Errorf("failed to create builder: %v", err) } // Create Builder Configuration - err = postRequest(httpEndpoint, fmt.Sprintf("/builders/configuration/%s", input.BuilderID), input.Config) + _, err = postRequest(builderAdminApi, fmt.Sprintf("/builders/configuration/%s", input.BuilderID), input.Config) if err != nil { - return err + return fmt.Errorf("failed to set builder configuration: %v", err) + } + + // Create Builder Secrets + _, err = postRequest(builderAdminApi, fmt.Sprintf("/builders/secrets/%s", input.BuilderID), input.Config) + if err != nil { + return fmt.Errorf("failed to set builder secrets: %v", err) } // Enable Builder - err = postRequest(httpEndpoint, fmt.Sprintf("/builders/activation/%s", input.BuilderID), map[string]interface{}{ + _, err = postRequest(builderAdminApi, fmt.Sprintf("/builders/activation/%s", input.BuilderID), map[string]interface{}{ "enabled": true, }) if err != nil { - return err + return fmt.Errorf("failed to activate builder: %v", err) } return nil } +func replaceEnodeIP(enodeRaw, vmHostIP string) string { + re := regexp.MustCompile(`@[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:`) + return re.ReplaceAllString(enodeRaw, "@"+vmHostIP+":") +} + // YAMLToJSON converts a YAML string to a JSON string func yamlToJson(yamlStr []byte) ([]byte, error) { // Unmarshal YAML into a map diff --git a/playground/recipe_yaml.go b/playground/recipe_yaml.go index f1b3c871..1d3b4495 100644 --- a/playground/recipe_yaml.go +++ b/playground/recipe_yaml.go @@ -86,6 +86,22 @@ type YAMLServiceConfig struct { // ReadyCheck is a URL to check for service readiness (used for health checks) // Format: "http://localhost:PORT/path" - the service is ready when this URL returns 200 ReadyCheck string `yaml:"ready_check,omitempty"` + + // LifecycleHooks enables lifecycle mode for host execution + // When true, init/start/stop commands are used instead of host_path/release + LifecycleHooks bool `yaml:"lifecycle_hooks,omitempty"` + + // Init commands run sequentially before start. Each must return exit code 0. + // Only used when lifecycle_hooks is true + Init []string `yaml:"init,omitempty"` + + // Start command runs the service. May hang (long-running) or return 0. + // Only used when lifecycle_hooks is true + Start string `yaml:"start,omitempty"` + + // Stop commands run when playground exits. May return non-zero (best effort). + // Only used when lifecycle_hooks is true + Stop []string `yaml:"stop,omitempty"` } type YAMLVolumeMappedConfig struct { @@ -254,7 +270,7 @@ func (y *YAMLRecipe) applyModifications(ctx *ExContext, component *Component) { } // Find existing service - existingService := findService(component, serviceName) + existingService := component.FindService(serviceName) if serviceConfig.Remove { // Remove the service @@ -269,10 +285,10 @@ func (y *YAMLRecipe) applyModifications(ctx *ExContext, component *Component) { if existingService != nil { // Override existing service properties - applyServiceOverrides(existingService, serviceConfig, component) + applyServiceOverrides(existingService, serviceConfig, component, y.recipeDir) } else { // Create new service - newService := createServiceFromConfig(serviceName, serviceConfig, component) + newService := createServiceFromConfig(serviceName, serviceConfig, component, y.recipeDir) existingComponent.Services = append(existingComponent.Services, newService) } } @@ -311,23 +327,6 @@ func removeComponent(root *Component, name string) { } } -// findService finds a service by name in the component tree -func findService(root *Component, name string) *Service { - for _, svc := range root.Services { - if svc.Name == name { - return svc - } - } - - for _, inner := range root.Inner { - if found := findService(inner, name); found != nil { - return found - } - } - - return nil -} - // removeService removes a service from the component tree func removeService(root *Component, name string) { for i, svc := range root.Services { @@ -424,7 +423,7 @@ func containsRemovedServiceRef(arg string, removedServices map[string]bool) bool } // applyServiceOverrides applies YAML config overrides to an existing service -func applyServiceOverrides(svc *Service, config *YAMLServiceConfig, root *Component) { +func applyServiceOverrides(svc *Service, config *YAMLServiceConfig, root *Component, recipeDir string) { if config.Image != "" { svc.Image = config.Image } @@ -476,6 +475,14 @@ func applyServiceOverrides(svc *Service, config *YAMLServiceConfig, root *Compon if config.ReadyCheck != "" { svc.WithReady(ReadyCheck{QueryURL: config.ReadyCheck}) } + if config.LifecycleHooks { + svc.LifecycleHooks = true + svc.Init = config.Init + svc.Start = config.Start + svc.Stop = config.Stop + svc.RecipeDir = recipeDir + svc.UseHostExecution() + } } // applyReplaceArgs replaces arguments in the existing args list. @@ -591,7 +598,7 @@ func applyDependsOn(svc *Service, dependsOn []string, root *Component) { } // createServiceFromConfig creates a new service from YAML config -func createServiceFromConfig(name string, config *YAMLServiceConfig, root *Component) *Service { +func createServiceFromConfig(name string, config *YAMLServiceConfig, root *Component, recipeDir string) *Service { svc := &Service{ Name: name, Args: []string{}, @@ -645,6 +652,14 @@ func createServiceFromConfig(name string, config *YAMLServiceConfig, root *Compo if config.ReadyCheck != "" { svc.WithReady(ReadyCheck{QueryURL: config.ReadyCheck}) } + if config.LifecycleHooks { + svc.LifecycleHooks = true + svc.Init = config.Init + svc.Start = config.Start + svc.Stop = config.Stop + svc.RecipeDir = recipeDir + svc.UseHostExecution() + } return svc } diff --git a/playground/recipe_yaml_test.go b/playground/recipe_yaml_test.go index 32f0d6fa..29261fb9 100644 --- a/playground/recipe_yaml_test.go +++ b/playground/recipe_yaml_test.go @@ -63,58 +63,6 @@ func TestRemoveComponent(t *testing.T) { } } -func TestFindService(t *testing.T) { - root := &Component{ - Name: "root", - Services: []*Service{{Name: "root-svc"}}, - Inner: []*Component{ - { - Name: "child", - Services: []*Service{{Name: "child-svc"}}, - }, - }, - } - - tests := []struct { - name string - search string - expected bool - }{ - {"find root service", "root-svc", true}, - {"find child service", "child-svc", true}, - {"not found", "nonexistent", false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := findService(root, tt.search) - if tt.expected { - require.NotNil(t, result) - } else { - require.Nil(t, result) - } - }) - } -} - -func TestRemoveService(t *testing.T) { - root := &Component{ - Name: "root", - Services: []*Service{ - {Name: "svc1"}, - {Name: "svc2"}, - {Name: "svc3"}, - }, - } - - removeService(root, "svc2") - - require.Len(t, root.Services, 2) - for _, s := range root.Services { - require.NotEqual(t, "svc2", s.Name) - } -} - func TestCollectServiceNames(t *testing.T) { root := &Component{ Name: "root", @@ -176,7 +124,7 @@ func TestApplyServiceOverrides(t *testing.T) { Env: map[string]string{"KEY": "value"}, } - applyServiceOverrides(svc, config, nil) + applyServiceOverrides(svc, config, nil, "") require.Equal(t, "new-image", svc.Image) require.Equal(t, "new-tag", svc.Tag) @@ -194,7 +142,7 @@ func TestApplyServiceOverrides_PartialOverride(t *testing.T) { config := &YAMLServiceConfig{Tag: "new-tag"} - applyServiceOverrides(svc, config, nil) + applyServiceOverrides(svc, config, nil, "") require.Equal(t, "original-image", svc.Image) require.Equal(t, "new-tag", svc.Tag) @@ -243,7 +191,7 @@ func TestCreateServiceFromConfig(t *testing.T) { DependsOn: []string{"db:healthy"}, } - svc := createServiceFromConfig("my-service", config, nil) + svc := createServiceFromConfig("my-service", config, nil, "") require.Equal(t, "my-service", svc.Name) require.Equal(t, "test-image", svc.Image) @@ -368,26 +316,6 @@ func TestApplyFilesToService(t *testing.T) { require.Equal(t, "genesis.json", svc.FilesMapped["/app/genesis.json"]) } -func TestRemoveService_Nested(t *testing.T) { - root := &Component{ - Name: "root", - Inner: []*Component{ - { - Name: "child", - Services: []*Service{ - {Name: "nested-svc1"}, - {Name: "nested-svc2"}, - }, - }, - }, - } - - removeService(root, "nested-svc1") - - require.Len(t, root.Inner[0].Services, 1) - require.Equal(t, "nested-svc2", root.Inner[0].Services[0].Name) -} - func TestApplyServiceOverrides_AllFields(t *testing.T) { svc := &Service{ Name: "test-svc", @@ -411,7 +339,7 @@ func TestApplyServiceOverrides_AllFields(t *testing.T) { }, } - applyServiceOverrides(svc, config, nil) + applyServiceOverrides(svc, config, nil, "") require.Equal(t, "new-image", svc.Image) require.Equal(t, "v2.0.0", svc.Tag) @@ -454,7 +382,7 @@ func TestCreateServiceFromConfig_WithHostPath(t *testing.T) { HostPath: "/usr/local/bin/myapp", } - svc := createServiceFromConfig("my-service", config, nil) + svc := createServiceFromConfig("my-service", config, nil, "") require.Equal(t, "/usr/local/bin/myapp", svc.HostPath) } @@ -469,7 +397,7 @@ func TestCreateServiceFromConfig_WithRelease(t *testing.T) { }, } - svc := createServiceFromConfig("my-service", config, nil) + svc := createServiceFromConfig("my-service", config, nil, "") require.NotNil(t, svc.release) require.Equal(t, "myapp", svc.release.Name) @@ -483,7 +411,7 @@ func TestCreateServiceFromConfig_WithVolumes(t *testing.T) { }}, } - svc := createServiceFromConfig("my-service", config, nil) + svc := createServiceFromConfig("my-service", config, nil, "") require.NotNil(t, svc.VolumesMapped) require.Equal(t, "myvolume", svc.VolumesMapped["/data"].Name) @@ -680,7 +608,7 @@ recipe: component := recipe.Apply(ctx) require.NotNil(t, component) - require.Nil(t, findService(component, "mev-boost-relay")) + require.Nil(t, component.FindService("mev-boost-relay")) } func TestYAMLRecipe_ApplyModifications_AddNewService(t *testing.T) { @@ -715,7 +643,7 @@ recipe: component := recipe.Apply(ctx) require.NotNil(t, component) - newSvc := findService(component, "new-svc") + newSvc := component.FindService("new-svc") require.NotNil(t, newSvc) require.Equal(t, "new-image", newSvc.Image) } @@ -879,8 +807,179 @@ func TestApplyServiceOverrides_WithReplaceArgs(t *testing.T) { config := &YAMLServiceConfig{ ReplaceArgs: tt.replaceArgs, } - applyServiceOverrides(svc, config, nil) + applyServiceOverrides(svc, config, nil, "") require.Equal(t, tt.expected, svc.Args) }) } } + +func TestYAMLRecipe_Lifecycle_ParseAndApply(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "recipe-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + yamlContent := `base: l1 +recipe: + new-component: + services: + lifecycle-svc: + lifecycle_hooks: true + init: + - echo init1 + - echo init2 + start: echo start && sleep infinity + stop: + - echo stop1 + - echo stop2 +` + yamlFile := filepath.Join(tmpDir, "recipe.yaml") + require.NoError(t, os.WriteFile(yamlFile, []byte(yamlContent), 0o644)) + + baseRecipes := []Recipe{&L1Recipe{}} + recipe, err := ParseYAMLRecipe(yamlFile, baseRecipes) + require.NoError(t, err) + + out, err := NewOutput(tmpDir) + require.NoError(t, err) + ctx := &ExContext{ + LogLevel: LevelInfo, + Contender: &ContenderContext{Enabled: false}, + Output: out, + } + + component := recipe.Apply(ctx) + require.NotNil(t, component) + + svc := component.FindService("lifecycle-svc") + require.NotNil(t, svc) + require.True(t, svc.LifecycleHooks) + require.Equal(t, []string{"echo init1", "echo init2"}, svc.Init) + require.Equal(t, "echo start && sleep infinity", svc.Start) + require.Equal(t, []string{"echo stop1", "echo stop2"}, svc.Stop) + + // Verify host execution is enabled by checking the label exists + require.NotNil(t, svc.Labels) + require.NotEmpty(t, svc.Labels) +} + +func TestYAMLRecipe_Lifecycle_StartOnly(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "recipe-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + yamlContent := `base: l1 +recipe: + new-component: + services: + lifecycle-svc: + lifecycle_hooks: true + start: ./run-server.sh +` + yamlFile := filepath.Join(tmpDir, "recipe.yaml") + require.NoError(t, os.WriteFile(yamlFile, []byte(yamlContent), 0o644)) + + baseRecipes := []Recipe{&L1Recipe{}} + recipe, err := ParseYAMLRecipe(yamlFile, baseRecipes) + require.NoError(t, err) + + out, err := NewOutput(tmpDir) + require.NoError(t, err) + ctx := &ExContext{ + LogLevel: LevelInfo, + Contender: &ContenderContext{Enabled: false}, + Output: out, + } + + component := recipe.Apply(ctx) + require.NotNil(t, component) + + svc := component.FindService("lifecycle-svc") + require.NotNil(t, svc) + require.True(t, svc.LifecycleHooks) + require.Empty(t, svc.Init) + require.Equal(t, "./run-server.sh", svc.Start) + require.Empty(t, svc.Stop) +} + +func TestYAMLRecipe_Lifecycle_InitOnly(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "recipe-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + yamlContent := `base: l1 +recipe: + new-component: + services: + lifecycle-svc: + lifecycle_hooks: true + init: + - echo "setup step 1" + - echo "setup step 2" +` + yamlFile := filepath.Join(tmpDir, "recipe.yaml") + require.NoError(t, os.WriteFile(yamlFile, []byte(yamlContent), 0o644)) + + baseRecipes := []Recipe{&L1Recipe{}} + recipe, err := ParseYAMLRecipe(yamlFile, baseRecipes) + require.NoError(t, err) + + out, err := NewOutput(tmpDir) + require.NoError(t, err) + ctx := &ExContext{ + LogLevel: LevelInfo, + Contender: &ContenderContext{Enabled: false}, + Output: out, + } + + component := recipe.Apply(ctx) + require.NotNil(t, component) + + svc := component.FindService("lifecycle-svc") + require.NotNil(t, svc) + require.True(t, svc.LifecycleHooks) + require.Equal(t, []string{"echo \"setup step 1\"", "echo \"setup step 2\""}, svc.Init) + require.Empty(t, svc.Start) + require.Empty(t, svc.Stop) +} + +func TestYAMLRecipe_Lifecycle_InitAndStop(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "recipe-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + yamlContent := `base: l1 +recipe: + new-component: + services: + lifecycle-svc: + lifecycle_hooks: true + init: + - echo "setup" + stop: + - echo "cleanup" +` + yamlFile := filepath.Join(tmpDir, "recipe.yaml") + require.NoError(t, os.WriteFile(yamlFile, []byte(yamlContent), 0o644)) + + baseRecipes := []Recipe{&L1Recipe{}} + recipe, err := ParseYAMLRecipe(yamlFile, baseRecipes) + require.NoError(t, err) + + out, err := NewOutput(tmpDir) + require.NoError(t, err) + ctx := &ExContext{ + LogLevel: LevelInfo, + Contender: &ContenderContext{Enabled: false}, + Output: out, + } + + component := recipe.Apply(ctx) + require.NotNil(t, component) + + svc := component.FindService("lifecycle-svc") + require.NotNil(t, svc) + require.True(t, svc.LifecycleHooks) + require.Equal(t, []string{"echo \"setup\""}, svc.Init) + require.Empty(t, svc.Start) + require.Equal(t, []string{"echo \"cleanup\""}, svc.Stop) +} diff --git a/playground/utils/builderhub-config.yaml b/playground/utils/builderhub-config.yaml new file mode 100644 index 00000000..acba3d4c --- /dev/null +++ b/playground/utils/builderhub-config.yaml @@ -0,0 +1,68 @@ +bidding_service: + config: | + slot_delta_to_start_bidding_ms = -20000 + subsidy = "0" + +rbuilder: + enabled: true + # blocklist: "" + evm_caching_enable: true + ignore_blobs: false + extra_data: BuilderNet (playground) + require_non_empty_blocklist: false + root_hash_threads: 4 + # BLS secret key for signing relay submissions (matches mev-boost-relay default) + relay_secret_key: "0x5eae315483f028b5cdd5d1090ff0c7618b18737ea9bf3c35047189db22835c48" + # Builder: 0x74085Fbe5108CF75F91951DDfD06d3f7d6890EF7 + coinbase_secret_key: "0x6a4254d9cb2f348290f65002d4e45cb7a447f287ef03ac226a3710ee91c9bc80" + # 0 for debugging + watchdog_timeout_sec: 0 + max_order_execution_duration_warning_us: 50000 + time_to_keep_mempool_txs_secs: 300 + live_builders: '["mgp-ordering", "mp-ordering"]' + system_recipient_allowlist: '[]' + builders: | + [[builders]] + name = "mgp-ordering" + algo = "ordering-builder" + discard_txs = true + sorting = "mev-gas-price" + failed_order_retries = 1 + drop_failed_orders = true + + [[builders]] + name = "mp-ordering" + algo = "ordering-builder" + discard_txs = true + sorting = "max-profit" + failed_order_retries = 1 + drop_failed_orders = true + + relays: | + [[relays]] + name = "playground" + url = "http://10.0.2.2:5555" + + relay_bid_scrapers: | + [[relay_bid_scrapers]] + type = "external-ws" + name = "playground" + url = "ws://10.0.2.2:5555" + auth_header = "" + +disk_encryption: + key: 5d7052c0c3aff5834f45e3f33aca0a55ef9f43ca9cf6c5c8e8375ab82564ddb6 # playground value +instance_name: buildernet-playground-vm + +playground: + builder_hub_config: + builder_id: "playground_vm_builder" + builder_ip: "1.2.3.4" + measurement_id: "test1" + network: "playground" + artifacts_url: http://10.0.2.2:8100 + # Reth P2P bootnode + el_bootnode: "{{EL_BOOTNODE}}" + # Lighthouse libp2p multiaddr + # Format: /ip4//tcp//p2p/ + cl_libp2p_addr: "{{CL_LIBP2P_ADDR}}"