Skip to content
Merged
42 changes: 32 additions & 10 deletions custom-recipes/buildernet/mkosi/scripts/prepare.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
#!/usr/bin/env bash
# Extract VM image and create data disk
#
# Usage:
# ./prepare.sh # Use default build output
# ./prepare.sh /path/to/image.qcow2 # Use local image
# ./prepare.sh https://example.com/img.qcow2 # Download from URL
#
# Environment:
# BUILDERNET_IMAGE - Path or URL to VM image (overridden by $1 argument)
set -eu -o pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
Expand All @@ -8,24 +16,38 @@ PROJECT_DIR="${SCRIPT_DIR}/.."
FLASHBOTS_IMAGES_DIR="${PROJECT_DIR}/.flashbots-images"
RUNTIME_DIR="${PROJECT_DIR}/.runtime"

QEMU_QCOW2="${FLASHBOTS_IMAGES_DIR}/mkosi.output/buildernet-qemu_latest.qcow2"
DEFAULT_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 ./scripts/build.sh first."
exit 1
fi
# Determine source image: $1 > $BUILDERNET_IMAGE > default build output
SOURCE="${1:-${BUILDERNET_IMAGE:-${DEFAULT_QCOW2}}}"

echo "prepare.sh: PROJECT_DIR=${PROJECT_DIR}"
echo "prepare.sh: RUNTIME_DIR=${RUNTIME_DIR}"
echo "prepare.sh: SOURCE=${SOURCE}"

rm -rf "${RUNTIME_DIR}"
mkdir -p "${RUNTIME_DIR}"

rm -f "${VM_IMAGE}"
cp --sparse=always "${QEMU_QCOW2}" "${VM_IMAGE}"
if [[ "${SOURCE}" =~ ^https?:// ]]; then
echo "prepare.sh: downloading from URL..."
curl -fSL -o "${VM_IMAGE}" "${SOURCE}"
elif [[ -f "${SOURCE}" ]]; then
echo "prepare.sh: copying local file ($(du -h "${SOURCE}" | cut -f1))..."
cp --sparse=always "${SOURCE}" "${VM_IMAGE}"
else
echo "Error: VM image not found: ${SOURCE}"
if [[ "${SOURCE}" == "${DEFAULT_QCOW2}" ]]; then
echo "Run './scripts/sync.sh && ./scripts/build.sh' first, or pass a path/URL as argument."
fi
echo "Usage: ./scripts/prepare.sh [/path/to/image.qcow2 | https://url/to/image.qcow2]"
exit 1
fi

echo "prepare.sh: creating data disk..."
qemu-img create -f raw "${VM_DATA_DISK}" 100G

echo "Runtime ready: ${RUNTIME_DIR}"
ls -lah "${RUNTIME_DIR}"
echo "prepare.sh: runtime ready"
ls -lah "${RUNTIME_DIR}"
64 changes: 32 additions & 32 deletions custom-recipes/buildernet/mkosi/scripts/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ PIDFILE="${RUNTIME_DIR}/qemu.pid"
CONSOLE_LOG="${RUNTIME_DIR}/console.log"
CONSOLE_SOCK="${RUNTIME_DIR}/console.sock"

CPU=8
RAM=32G
CPU="${QEMU_CPU:-8}"
RAM="${QEMU_RAM:-32G}"
SSH_PORT=2222
OPERATOR_API_PORT=13535
RBUILDER_RPC_PORT=18645
Expand All @@ -30,7 +30,28 @@ if [[ -f "${PIDFILE}" ]] && kill -0 $(cat "${PIDFILE}") 2>/dev/null; then
exit 1
fi

# Determine acceleration mode
ACCEL="${QEMU_ACCEL:-kvm}"
if [[ "${ACCEL}" == "kvm" ]]; then
if [[ ! -e /dev/kvm ]]; then
echo "Error: KVM is not available (/dev/kvm not found)."
echo "Options:"
echo " - Enable KVM on this host (load kvm kernel module)"
echo " - Use software emulation: QEMU_ACCEL=tcg ./scripts/start.sh"
echo " (TCG is ~10-20x slower but works anywhere)"
exit 1
fi
QEMU_ACCEL_ARGS="-enable-kvm -cpu host"
elif [[ "${ACCEL}" == "tcg" ]]; then
QEMU_ACCEL_ARGS="-accel tcg -cpu max"
else
echo "Error: Unknown QEMU_ACCEL value: ${ACCEL} (expected 'kvm' or 'tcg')"
exit 1
fi

echo "Starting VM..."
echo " Accel: ${ACCEL}"
echo " CPU: ${CPU} cores, RAM: ${RAM}"
echo " SSH: localhost:${SSH_PORT}"
echo " Operator API: localhost:${OPERATOR_API_PORT}"
echo " rbuilder RPC: localhost:${RBUILDER_RPC_PORT}"
Expand All @@ -41,16 +62,22 @@ echo " Console socket: ${CONSOLE_SOCK}"

source "${SCRIPT_DIR}/ovmf.sh"

qemu-system-x86_64 \
-daemonize \
echo "start.sh: launching qemu-system-x86_64..."
echo "start.sh: QEMU_ACCEL_ARGS=${QEMU_ACCEL_ARGS}"
echo "start.sh: VM_IMAGE=${VM_IMAGE} ($(du -h "${VM_IMAGE}" | cut -f1))"
echo "start.sh: VM_DATA_DISK=${VM_DATA_DISK}"

# exec replaces this shell with QEMU so the playground can track and kill
# the process directly. QEMU runs in the foreground (no -daemonize).
exec qemu-system-x86_64 \
-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 \
${QEMU_ACCEL_ARGS} -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 \
Expand All @@ -59,30 +86,3 @@ qemu-system-x86_64 \
-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 './scripts/stop.sh' to stop, './scripts/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[@]}")"
61 changes: 32 additions & 29 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,35 +35,36 @@ var (
)

var (
keepFlag bool
outputFlag string
genesisDelayFlag uint64
withOverrides []string
watchdog bool
dryRun bool
interactive bool
timeout time.Duration
logLevelFlag string
bindExternal bool
withPrometheus bool
networkName string
labels playground.MapStringFlag
disableLogs bool
platform string
contenderEnabled bool
contenderArgs []string
contenderTarget string
detached bool
skipSetup bool
prefundedAccounts []string
followFlag bool
generateForce bool
testRPCURL string
testELRPCURL string
testTimeout time.Duration
testRetries int
testInsecure bool
portListFlag bool
keepFlag bool
outputFlag string
genesisDelayFlag uint64
withOverrides []string
watchdog bool
dryRun bool
interactive bool
timeout time.Duration
logLevelFlag string
bindExternal bool
withPrometheus bool
networkName string
labels playground.MapStringFlag
disableLogs bool
platform string
contenderEnabled bool
contenderArgs []string
contenderTarget string
detached bool
skipSetup bool
prefundedAccounts []string
followFlag bool
generateForce bool
testRPCURL string
testELRPCURL string
testTimeout time.Duration
testRetries int
testInsecure bool
testExpectedExtraData string
portListFlag bool
)

var rootCmd = &cobra.Command{
Expand Down Expand Up @@ -519,6 +520,7 @@ var testCmd = &cobra.Command{
cfg.Timeout = testTimeout
cfg.Retries = testRetries
cfg.Insecure = testInsecure
cfg.ExpectedExtraData = testExpectedExtraData

// Suggest --insecure flag if any RPC URL uses https without insecure mode
for _, rpcURL := range []string{cfg.RPCURL, cfg.ELRPCURL} {
Expand Down Expand Up @@ -641,6 +643,7 @@ func main() {
testCmd.Flags().DurationVar(&testTimeout, "timeout", time.Minute, "Timeout for waiting for transaction receipt (0 means no timeout - default: 1m)")
testCmd.Flags().IntVar(&testRetries, "retries", 0, "Max number of failed receipt requests before giving up (0 means retry forever - default: 0)")
testCmd.Flags().BoolVar(&testInsecure, "insecure", false, "Skip TLS certificate verification (for self-signed certs)")
testCmd.Flags().StringVar(&testExpectedExtraData, "expected-extra-data", "", "Verify block extra data matches this string (fails if different)")
rootCmd.AddCommand(testCmd)

if err := rootCmd.Execute(); err != nil {
Expand Down
9 changes: 8 additions & 1 deletion playground/local_runner_lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,15 @@ func (d *LocalRunner) startWithLifecycleHooks(ctx context.Context, svc *Service)
lc.logHeader("Start", -1, svc.Start)

startCmd := lc.newCmd(ctx, svc.Start)
// Use Start() instead of Run() in a goroutine to ensure the process is
// launched before we return. This is important for --detached mode where
// the main process exits right after lifecycle hooks complete.
if err := startCmd.Start(); err != nil {
return fmt.Errorf("%s", lc.formatError("start", svc.Start, err))
}

go func() {
if err := startCmd.Run(); err != nil {
if err := startCmd.Wait(); err != nil {
if mainctx.IsExiting() {
return
}
Expand Down
28 changes: 17 additions & 11 deletions playground/test_tx.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,17 @@ func (t *buildernetSigningTransport) RoundTrip(req *http.Request) (*http.Respons

// TestTxConfig holds configuration for the test transaction
type TestTxConfig struct {
RPCURL string // Target RPC URL for sending transactions (e.g., rbuilder)
ELRPCURL string // EL RPC URL for chain queries (e.g., reth). If empty, uses RPCURL
PrivateKey string
ToAddress string
Value *big.Int
GasLimit uint64
GasPrice *big.Int
Timeout time.Duration // Timeout for waiting for receipt. If 0, no timeout.
Retries int // Max failed receipt requests before giving up. If 0, retry forever.
Insecure bool // Skip TLS certificate verification
RPCURL string // Target RPC URL for sending transactions (e.g., rbuilder)
ELRPCURL string // EL RPC URL for chain queries (e.g., reth). If empty, uses RPCURL
PrivateKey string
ToAddress string
Value *big.Int
GasLimit uint64
GasPrice *big.Int
Timeout time.Duration // Timeout for waiting for receipt. If 0, no timeout.
Retries int // Max failed receipt requests before giving up. If 0, retry forever.
Insecure bool // Skip TLS certificate verification
ExpectedExtraData string // If set, verify block extra data matches this string
}

// DefaultTestTxConfig returns the default test transaction configuration
Expand Down Expand Up @@ -240,7 +241,12 @@ func SendTestTransaction(ctx context.Context, cfg *TestTxConfig) error {
// Get block to show extra data (builder name)
block, err := elClient.BlockByNumber(ctx, receipt.BlockNumber)
if err == nil && block != nil {
fmt.Printf(" Extra Data: %s\n", string(block.Extra()))
extraData := string(block.Extra())
fmt.Printf(" Extra Data: %s\n", extraData)

if cfg.ExpectedExtraData != "" && extraData != cfg.ExpectedExtraData {
return fmt.Errorf("extra data mismatch: expected %q, got %q", cfg.ExpectedExtraData, extraData)
}
}
return nil
}
Expand Down