#!/usr/bin/env bash
# docker-run — convenience launcher for the dockerized node stack.
#
# Defaults to direct host-port mode (matches a plain `docker compose up`).
# Add `--proxy` to flip into Caddy reverse-proxy mode at invocation time
# without changing the default compose behaviour — that lets bare-metal
# users + existing CI scripts keep working unchanged.
#
# Usage:
# ./scripts/docker-run [--proxy] [--devnet] [--port
] [--db
] [--detach|-d] [docker-compose-args...]
#
# Flags handled here:
# --proxy Merge docker-compose.proxy.yml + enable the `proxy`,
# `monitoring`, and `tlsnotary` compose profiles. Drops
# redundant host port mappings; only 80, 443, 443/udp,
# and 53551 (OmniProtocol) remain published.
# --devnet Run a SECOND isolated stack alongside mainnet/testnet.
# Merges docker-compose.devnet.yml under the `demos-devnet`
# compose project: container names -> demos-*-devnet, network
# -> demos-network-devnet, volumes -> demos_*_devnet, and host
# ports offset +100 (RPC 53650, Omni 53651, signaling 3105,
# metrics 9190, ...). Composable with --clean/--rebuild/-d.
# Also syncs host data/genesis.json into the devnet node_data
# volume on every up (devnet-only) so genesis/validator-set
# edits take effect without a --rebuild.
# --port
Node RPC host port (default 53550). A NON-default port runs
# a fully isolated extra instance: compose project demos-
,
# container names demos-*_
, network demos-network_
,
# volumes demos_*_
. Every other published host port shifts
# by the same offset (
- 53550): Omni, signaling, messaging, metrics,
# tlsnotary, prometheus, grafana, node-exporter, neo4j. Pick
# instance ports 100 apart (53650, 53750, ...) so the derived
# ports of different instances never overlap. Stopping or
# wiping one instance (`--port
down -v`, `--port
# --clean`) never touches another. Not combinable with
# --devnet (which pins its own ports/names); --proxy is not
# multi-instance safe (Caddy binds fixed 80/443).
# --db
Publish PostgreSQL on host port
(internal-only without
# this flag). Merges docker-compose.db-port.yml and exports
# PG_HOST_PORT=
. Composable with --port.
# --no-monitor Drop the `monitoring` profile from the proxy bundle.
# --no-tlsn Drop the `tlsnotary` profile from the proxy bundle.
# --clean Wipe DB + bundled-data volumes (pgdata, node_data) before
# `up`. Equivalent to bare-metal `./run -b true`. Brings down
# the stack first, then `docker volume rm` the project-scoped
# volumes. Snapshot-restore re-runs from genesis on next boot.
# --rebuild Rebuild the node image with --no-cache before `up` (or
# `build`), then prune build cache older than 24h. A plain
# `up` already rebuilds on source changes; reserve this for
# a suspect cache. Implied by nothing — pass explicitly.
# --detach,-d Detach after start (`docker compose up -d`).
# --help,-h Print this help.
#
# Identity sync (always-on, no flag):
# On every `up`, if `$REPO_ROOT/.demos_identity` exists, it is copied
# into the `_node_state` volume (overwriting any prior copy)
# and chmod'd 0600 / chown'd 1000:1000 to match the container user.
# Mirrors the bare-metal `./run` behavior which reads the file
# directly. To boot with a fresh auto-generated identity, delete
# `$REPO_ROOT/.demos_identity` first.
#
# Everything else is forwarded to `docker compose` after the `up`
# subcommand. Examples:
# ./scripts/docker-run # plain dev: postgres + node
# ./scripts/docker-run --proxy -d # full prod stack, detached
# ./scripts/docker-run --proxy --no-monitor # proxy without Grafana/Prom
# ./scripts/docker-run down # teardown
# ./scripts/docker-run --proxy down # teardown w/ proxy stack
# ./scripts/docker-run --clean # wipe volumes + fresh up
# ./scripts/docker-run --clean --proxy -d # wipe + full prod stack
# ./scripts/docker-run --rebuild --clean -d # rebuild image + wipe + up
# ./scripts/docker-run --rebuild -d # rebuild image (no wipe), up
# ./scripts/docker-run --devnet -d # devnet stack alongside mainnet
# ./scripts/docker-run --devnet --clean -d # wipe devnet vols + fresh up
# ./scripts/docker-run --devnet down # tear devnet down only
# ./scripts/docker-run --port 53650 --db 5433 -d # second isolated instance
# ./scripts/docker-run --port 53650 down # tear that instance down only
# ./scripts/docker-run --port 53650 down -v # ...and delete its volumes
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
USE_PROXY=false
USE_DEVNET=false
WANT_MONITORING=true
WANT_TLSN=true
WIPE_VOLUMES=false
REBUILD_IMAGE=false
NODE_PORT=""
DB_PORT=""
PASSTHROUGH=()
EXTRA_PROFILES=()
while [[ $# -gt 0 ]]; do
case "$1" in
--proxy)
USE_PROXY=true
shift
;;
--devnet)
# Run a second, fully isolated stack alongside mainnet/testnet.
# Merges docker-compose.devnet.yml (remaps container names,
# network, volumes -> *_devnet, host ports +100) and runs under
# a distinct compose project name so nothing collides.
USE_DEVNET=true
shift
;;
--no-monitor|--no-monitoring)
WANT_MONITORING=false
shift
;;
--no-tlsn|--no-tlsnotary)
WANT_TLSN=false
shift
;;
--port)
if [[ $# -lt 2 || ! "${2:-}" =~ ^[0-9]+$ ]]; then
echo "scripts/docker-run: --port requires a numeric value" >&2
exit 2
fi
NODE_PORT="$2"
shift 2
;;
--db)
if [[ $# -lt 2 || ! "${2:-}" =~ ^[0-9]+$ ]]; then
echo "scripts/docker-run: --db requires a numeric value" >&2
exit 2
fi
DB_PORT="$2"
shift 2
;;
--clean)
WIPE_VOLUMES=true
shift
;;
--rebuild)
REBUILD_IMAGE=true
shift
;;
--profile)
# Capture --profile NAME so it lands BEFORE the subcommand
# (docker compose treats --profile as a global flag). Guard
# against `--profile` at end-of-args — without this, `set -u`
# aborts with "$2: unbound variable".
if [[ $# -lt 2 || -z "${2:-}" || "$2" == --* ]]; then
echo "scripts/docker-run: --profile requires a value" >&2
exit 2
fi
EXTRA_PROFILES+=(--profile "$2")
shift 2
;;
--detach|-d)
PASSTHROUGH+=("-d")
shift
;;
--help|-h)
# Print the full comment header (line 2 up to the first
# non-comment line) so the range never drifts as docs grow.
awk 'NR >= 2 { if ($0 !~ /^#/) exit; sub(/^# ?/, ""); print }' "$0"
exit 0
;;
*)
PASSTHROUGH+=("$1")
shift
;;
esac
done
# ---------------------------------------------------------------------------
# Multi-instance support (--port / --db).
#
# Default published host ports from docker-compose.yml. A non-default --port
# shifts every one of them by the same offset and namespaces the instance:
# compose project demos- + INSTANCE_SUFFIX=_ (interpolated into
# every container/network/volume `name:` in docker-compose.yml). The exports
# below take precedence over .env values (shell env > .env in compose
# interpolation) — intentional, mirroring how --devnet pins its ports.
# ---------------------------------------------------------------------------
DEFAULT_RPC_PORT=53550
DEFAULT_OMNI_PORT=53551
DEFAULT_SIGNALING_PORT=3005
DEFAULT_MESSAGING_PORT=3006
DEFAULT_GOSSIP_PORT=9095
DEFAULT_METRICS_HOST_PORT=9090
DEFAULT_TLSNOTARY_PORT=7047
DEFAULT_PROMETHEUS_PORT=9091
DEFAULT_GRAFANA_PORT=3000
DEFAULT_NODE_EXPORTER_PORT=9100
DEFAULT_NEO4J_HTTP_PORT=7474
DEFAULT_NEO4J_BOLT_PORT=7687
MIN_PORT=1
MAX_PORT=65535
# Exported unconditionally (empty by default) so a stray INSTANCE_SUFFIX
# inherited from the calling shell can never leak into compose interpolation.
INSTANCE_SUFFIX=""
export INSTANCE_SUFFIX
INSTANCE_PROJECT=""
if [[ -n "$NODE_PORT" ]]; then
if [[ "$USE_DEVNET" == "true" ]]; then
echo "scripts/docker-run: --port cannot be combined with --devnet (devnet pins its own ports/names)" >&2
exit 2
fi
if (( NODE_PORT < MIN_PORT || NODE_PORT > MAX_PORT )); then
echo "scripts/docker-run: --port must be in ${MIN_PORT}-${MAX_PORT}" >&2
exit 2
fi
PORT_OFFSET=$(( NODE_PORT - DEFAULT_RPC_PORT ))
export RPC_PORT="$NODE_PORT"
export OMNI_PORT=$(( DEFAULT_OMNI_PORT + PORT_OFFSET ))
export RPC_SIGNALING_PORT=$(( DEFAULT_SIGNALING_PORT + PORT_OFFSET ))
# The messaging port is published even while messaging is off, so every
# extra instance needs its own host port or `up` fails on a bind clash.
export L2PS_MESSAGING_PORT=$(( DEFAULT_MESSAGING_PORT + PORT_OFFSET ))
export GOSSIP_PORT=$(( DEFAULT_GOSSIP_PORT + PORT_OFFSET ))
export METRICS_HOST_PORT=$(( DEFAULT_METRICS_HOST_PORT + PORT_OFFSET ))
export TLSNOTARY_PORT=$(( DEFAULT_TLSNOTARY_PORT + PORT_OFFSET ))
export PROMETHEUS_PORT=$(( DEFAULT_PROMETHEUS_PORT + PORT_OFFSET ))
export GRAFANA_PORT=$(( DEFAULT_GRAFANA_PORT + PORT_OFFSET ))
export NODE_EXPORTER_PORT=$(( DEFAULT_NODE_EXPORTER_PORT + PORT_OFFSET ))
export NEO4J_HTTP_PORT=$(( DEFAULT_NEO4J_HTTP_PORT + PORT_OFFSET ))
export NEO4J_BOLT_PORT=$(( DEFAULT_NEO4J_BOLT_PORT + PORT_OFFSET ))
for derived_port in "$OMNI_PORT" "$RPC_SIGNALING_PORT" "$L2PS_MESSAGING_PORT" "$GOSSIP_PORT" "$METRICS_HOST_PORT" \
"$TLSNOTARY_PORT" "$PROMETHEUS_PORT" "$GRAFANA_PORT" \
"$NODE_EXPORTER_PORT" "$NEO4J_HTTP_PORT" "$NEO4J_BOLT_PORT"; do
if (( derived_port < MIN_PORT || derived_port > MAX_PORT )); then
echo "scripts/docker-run: derived host port ${derived_port} is out of range (offset ${PORT_OFFSET} from --port ${NODE_PORT}); pick a --port closer to ${DEFAULT_RPC_PORT}" >&2
exit 2
fi
done
# --port 53550 IS the default instance — no namespacing, so it keeps
# addressing the same containers/volumes as a flag-less invocation.
if (( NODE_PORT != DEFAULT_RPC_PORT )); then
INSTANCE_SUFFIX="_${NODE_PORT}"
export INSTANCE_SUFFIX
INSTANCE_PROJECT="demos-${NODE_PORT}"
fi
fi
if [[ -n "$DB_PORT" ]]; then
if (( DB_PORT < MIN_PORT || DB_PORT > MAX_PORT )); then
echo "scripts/docker-run: --db must be in ${MIN_PORT}-${MAX_PORT}" >&2
exit 2
fi
export PG_HOST_PORT="$DB_PORT"
fi
# Auto-enable tlsnotary profile when the node has TLSNOTARY_ENABLED=true
# in .env (or defaults to true via .env.example). The node will try to
# call tlsnotary:7047 on boot and crash-mark the subsystem if the
# container isn't there. Saves operators from the confusing "TLSN
# failed to initialize" error on a plain `./run --docker`.
# Read ONLY the TLSNOTARY_ENABLED value out of .env. We deliberately do
# NOT `source` .env here: it is a dotenv data file, not a shell script,
# so `. ./.env` would *execute* every line. Any unquoted value with
# spaces — e.g. `WHITELISTED_IPS=1.2.3.4, 5.6.7.8` — gets parsed as a
# command and spews ": command not found". Compose reads .env
# itself for ${VAR} interpolation, so the only thing this script needs
# from the file is the one flag below.
TLSNOTARY_ENABLED_RAW=""
if [[ -f .env ]]; then
line="$(grep -E '^[[:space:]]*TLSNOTARY_ENABLED[[:space:]]*=' .env | tail -n1 || true)"
line="${line#*=}" # strip the key, keep the value
line="${line%%#*}" # strip any inline comment
# strip surrounding quotes + whitespace
TLSNOTARY_ENABLED_RAW="$(printf '%s' "$line" | tr -d '"'\''[:space:]')"
fi
TLSNOTARY_ENABLED_RESOLVED="${TLSNOTARY_ENABLED_RAW:-true}"
if [[ "$USE_PROXY" != "true" \
&& "$TLSNOTARY_ENABLED_RESOLVED" == "true" \
&& "$WANT_TLSN" == "true" ]]; then
EXTRA_PROFILES+=(--profile tlsnotary)
fi
# Devnet runs under its own compose project name so its containers,
# default network, and project-scoped state are namespaced apart from the
# mainnet/testnet stack — both can run on the same host simultaneously.
# The devnet override file remaps the explicit `name:` volumes + host ports.
COMPOSE_ARGS=()
if [[ "$USE_DEVNET" == "true" ]]; then
if [[ ! -f docker-compose.devnet.yml ]]; then
echo "docker-compose.devnet.yml missing — cannot enable devnet mode." >&2
exit 1
fi
COMPOSE_ARGS+=(-p demos-devnet)
elif [[ -n "$INSTANCE_PROJECT" ]]; then
# Distinct project name is what guarantees compose never sees another
# instance's containers as orphans of this one (and vice versa).
COMPOSE_ARGS+=(-p "$INSTANCE_PROJECT")
fi
COMPOSE_ARGS+=(-f docker-compose.yml)
if [[ "$USE_DEVNET" == "true" ]]; then
COMPOSE_ARGS+=(-f docker-compose.devnet.yml)
fi
if [[ -n "$DB_PORT" ]]; then
if [[ ! -f docker-compose.db-port.yml ]]; then
echo "docker-compose.db-port.yml missing — cannot publish the DB port." >&2
exit 1
fi
COMPOSE_ARGS+=(-f docker-compose.db-port.yml)
fi
PROFILES=()
# Volume name set the script manages directly (wipe + identity/peerlist sync).
# Devnet remaps these to the *_devnet variants via the override's top-level
# `name:` keys; --port instances to the *_ variants via INSTANCE_SUFFIX
# — the helper steps below must target the matching names.
if [[ "$USE_DEVNET" == "true" ]]; then
CLEAN_VOLUMES=(demos_pgdata_devnet demos_node_data_devnet)
STATE_VOL_NAME="demos_node_state_devnet"
DATA_VOL_NAME="demos_node_data_devnet"
else
CLEAN_VOLUMES=("demos_pgdata${INSTANCE_SUFFIX}" "demos_node_data${INSTANCE_SUFFIX}")
STATE_VOL_NAME="demos_node_state${INSTANCE_SUFFIX}"
DATA_VOL_NAME="demos_node_data${INSTANCE_SUFFIX}"
fi
# Export build-time git provenance so `docker compose build` can bake it
# into the image (consumed by src/utilities/nodeVersion.ts and surfaced
# via getNetworkInfo.nodeVersion). Every var has a safe empty default
# so a host without git, or a non-repo working tree, still builds.
# `git diff-index --quiet HEAD` exits 1 when dirty, 0 when clean; map
# to a "true"/"false" string the node module understands.
if git -C "$(dirname "${BASH_SOURCE[0]}")/.." rev-parse --git-dir >/dev/null 2>&1; then
GIT_REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export GIT_COMMIT="$(git -C "$GIT_REPO_DIR" rev-parse HEAD 2>/dev/null || true)"
export GIT_BRANCH="$(git -C "$GIT_REPO_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
export GIT_COMMIT_MESSAGE="$(git -C "$GIT_REPO_DIR" log -1 --format=%s 2>/dev/null || true)"
# Refresh the index first so stat-cache drift (mtime/ctime updated by
# an editor that wrote a file back identically, or by file-mode bits
# touched on checkout) doesn't lie about dirty. After
# `update-index --refresh` only actual content diffs remain, and
# `git status --porcelain` covers both staged-or-not + untracked-but-
# not-ignored in one go (which `diff-index --quiet HEAD` misses).
git -C "$GIT_REPO_DIR" update-index --refresh >/dev/null 2>&1 || true
if [[ -z "$(git -C "$GIT_REPO_DIR" status --porcelain 2>/dev/null)" ]]; then
export GIT_DIRTY="false"
else
export GIT_DIRTY="true"
fi
fi
export BUILT_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
if [[ "$USE_PROXY" == "true" ]]; then
if [[ ! -f docker-compose.proxy.yml ]]; then
echo "docker-compose.proxy.yml missing — cannot enable proxy mode." >&2
exit 1
fi
COMPOSE_ARGS+=(-f docker-compose.proxy.yml)
PROFILES+=(--profile proxy)
[[ "$WANT_MONITORING" == "true" ]] && PROFILES+=(--profile monitoring)
[[ "$WANT_TLSN" == "true" ]] && PROFILES+=(--profile tlsnotary)
fi
# Determine the compose subcommand. First non-flag passthrough arg
# wins; default is `up` so the common case (`./scripts/docker-run`)
# brings the stack up.
SUBCOMMAND="up"
SUBCOMMAND_ARGS=()
if [[ ${#PASSTHROUGH[@]} -gt 0 ]]; then
case "${PASSTHROUGH[0]}" in
up|down|ps|logs|restart|stop|start|build|pull|exec|run|config|kill|rm|top|images|version|exec|port|wait|cp|create|events)
SUBCOMMAND="${PASSTHROUGH[0]}"
SUBCOMMAND_ARGS=("${PASSTHROUGH[@]:1}")
;;
*)
SUBCOMMAND_ARGS=("${PASSTHROUGH[@]}")
;;
esac
fi
# --clean: bring everything down + drop the named volumes before `up`.
# Only fires when SUBCOMMAND ends up as `up`; for explicit `down`/`logs`/etc
# the wipe would be confusing.
#
# Volume names are hardcoded as `demos_*` in docker-compose.yml `volumes:`
# section via `name:` overrides — NOT derived from the compose project
# name. Do NOT prefix with $(basename $REPO_ROOT) here; that targets a
# parallel volume that compose never mounts.
if [[ "$WIPE_VOLUMES" == "true" && "$SUBCOMMAND" == "up" ]]; then
echo "+ --clean: tearing down stack + wiping volumes (${CLEAN_VOLUMES[*]})"
# Down with --volumes so anonymous volumes go too; named volumes
# still need explicit removal below because compose only drops them
# when `--volumes` is paired with full project teardown AND no other
# containers reference them.
docker compose "${COMPOSE_ARGS[@]}" ${PROFILES[@]+"${PROFILES[@]}"} ${EXTRA_PROFILES[@]+"${EXTRA_PROFILES[@]}"} down --volumes --remove-orphans || true
for vol in "${CLEAN_VOLUMES[@]}"; do
if docker volume inspect "$vol" >/dev/null 2>&1; then
echo "+ --clean: removing volume $vol"
docker volume rm "$vol" || true
fi
done
fi
# --rebuild: force --no-cache rebuild of the node image before `up` or
# `build`. Runs after --clean so the fresh image pairs with an empty data
# dir. A no-cache build leaves the previous build's layers in the BuildKit
# cache, so prune everything older than 24h once the build succeeds.
if [[ "$REBUILD_IMAGE" == "true" && ( "$SUBCOMMAND" == "up" || "$SUBCOMMAND" == "build" ) ]]; then
echo "+ --rebuild: docker compose build --no-cache node"
docker compose "${COMPOSE_ARGS[@]}" build --no-cache node
echo "+ --rebuild: pruning build cache older than 24h"
docker builder prune -f --filter until=24h || true
fi
# Always-sync host `.demos_identity` -> `node_state` volume before `up`.
#
# Bare-metal `./run` reads `$REPO_ROOT/.demos_identity` directly. The
# docker flow stores identity inside the `demos_node_state` named volume,
# which is opaque to the host filesystem. Without this sync, operators
# who configured an identity at the repo root were silently bypassed:
# the container saw an empty volume on first boot, generated a fresh
# mnemonic, and pinned the wrong pubkey. The pubkey then mismatched any
# genesis-baked validator entry, leaving the node out of consensus.
#
# Sync rules:
# - Only fires for SUBCOMMAND=up.
# - Only fires when `$REPO_ROOT/.demos_identity` exists.
# - Overwrites the volume copy unconditionally — the repo file is the
# source of truth, including across --clean. Operators who want a
# fresh identity should `rm .demos_identity` first.
# - Sets the same perms the container's entrypoint expects
# (0600, owned by uid 1000 to match the demos user inside the image).
HOST_IDENTITY_FILE="${REPO_ROOT}/.demos_identity"
if [[ "$SUBCOMMAND" == "up" && -f "$HOST_IDENTITY_FILE" ]]; then
# Volume name comes from docker-compose.yml's `name:` override
# (`demos_node_state`), or its devnet variant (`demos_node_state_devnet`)
# — NOT derived from the compose project name. Match the literal name so
# the seeded mnemonic lands in the volume compose mounts at /app/state.
STATE_VOL="$STATE_VOL_NAME"
# Create the volume if it doesn't already exist so compose can mount it.
docker volume inspect "$STATE_VOL" >/dev/null 2>&1 || docker volume create "$STATE_VOL" >/dev/null
echo "+ syncing .demos_identity -> $STATE_VOL"
docker run --rm \
-v "$STATE_VOL":/state \
-v "$HOST_IDENTITY_FILE":/src/.demos_identity:ro \
alpine sh -c '
cp /src/.demos_identity /state/.demos_identity &&
chmod 600 /state/.demos_identity &&
chown 1000:1000 /state /state/.demos_identity
'
fi
# Always-sync host `demos_peerlist.json` -> `node_state` volume before `up`.
#
# Mirrors the .demos_identity sync above. Bare-metal `./run` reads
# `$REPO_ROOT/demos_peerlist.json` directly (via PEER_LIST_FILE); the docker
# flow keeps it inside the `demos_node_state` volume, surfaced at
# /app/demos_peerlist.json by the entrypoint symlink. Without this sync a
# dockerized node ignores the operator's bootstrap peer list and starts from
# an empty/auto-generated one on a fresh volume.
#
# Sync rules (same as identity):
# - Only fires for SUBCOMMAND=up.
# - Only fires when `$REPO_ROOT/demos_peerlist.json` exists.
# - Overwrites the volume copy unconditionally — the repo file is the
# source of truth, including across --clean (which does NOT wipe
# node_state). To boot without seeding peers, `rm demos_peerlist.json`.
# - 0644 (not a secret) owned by uid 1000 to match the container's demos
# user, so the node can read (and rewrite) it.
HOST_PEERLIST_FILE="${REPO_ROOT}/demos_peerlist.json"
if [[ "$SUBCOMMAND" == "up" && -f "$HOST_PEERLIST_FILE" ]]; then
# Same volume name as the identity sync above (mainnet or devnet variant).
STATE_VOL="$STATE_VOL_NAME"
docker volume inspect "$STATE_VOL" >/dev/null 2>&1 || docker volume create "$STATE_VOL" >/dev/null
echo "+ syncing demos_peerlist.json -> $STATE_VOL"
docker run --rm \
-v "$STATE_VOL":/state \
-v "$HOST_PEERLIST_FILE":/src/demos_peerlist.json:ro \
alpine sh -c '
cp /src/demos_peerlist.json /state/demos_peerlist.json &&
chmod 644 /state/demos_peerlist.json &&
chown 1000:1000 /state /state/demos_peerlist.json
'
fi
# DEVNET ONLY — sync host `data/genesis.json` -> node_data volume before `up`.
#
# Unlike identity/peerlist, genesis is normally baked into the image at build
# time (`COPY data/ ./data/` in the Dockerfile) and lives in the node_data
# volume thereafter. That means a host edit to data/genesis.json does NOT
# reach a running container without a full --rebuild — a sharp edge when
# iterating on a devnet validator set (wrong/old validators -> the node's
# pubkey is not in the shard -> NotInShardError loop).
#
# For devnet we copy the host genesis into the node_data volume on every `up`
# so editing data/genesis.json is enough. Deliberately devnet-ONLY: the
# mainnet/testnet stack must keep using the image-baked genesis as the single
# source of truth (silently overwriting a validator's genesis from a possibly
# stale host checkout would be dangerous). Operators who genuinely changed the
# mainnet genesis rebuild the image instead.
#
# Sync rules:
# - Only fires for SUBCOMMAND=up AND --devnet.
# - Only fires when `$REPO_ROOT/data/genesis.json` exists.
# - Overwrites the volume copy unconditionally — the repo file is the source
# of truth for devnet. Runs AFTER --clean (which wipes node_data) so a
# fresh volume is reseeded with the current host genesis.
# - 0644 owned by uid 1000 to match the container's demos user.
HOST_GENESIS_FILE="${REPO_ROOT}/data/genesis.json"
if [[ "$USE_DEVNET" == "true" && "$SUBCOMMAND" == "up" && -f "$HOST_GENESIS_FILE" ]]; then
docker volume inspect "$DATA_VOL_NAME" >/dev/null 2>&1 || docker volume create "$DATA_VOL_NAME" >/dev/null
echo "+ [devnet] syncing data/genesis.json -> $DATA_VOL_NAME"
docker run --rm \
-v "$DATA_VOL_NAME":/data \
-v "$HOST_GENESIS_FILE":/src/genesis.json:ro \
alpine sh -c '
cp /src/genesis.json /data/genesis.json &&
chmod 644 /data/genesis.json &&
chown 1000:1000 /data/genesis.json
'
fi
HOST_SNAPSHOT_DIR="${REPO_ROOT}/data/snapshot"
if [[ "$USE_DEVNET" == "true" && "$SUBCOMMAND" == "up" && -d "$HOST_SNAPSHOT_DIR" ]]; then
docker volume inspect "$DATA_VOL_NAME" >/dev/null 2>&1 || docker volume create "$DATA_VOL_NAME" >/dev/null
echo "+ [devnet] syncing data/snapshot/ -> $DATA_VOL_NAME"
docker run --rm \
-v "$DATA_VOL_NAME":/data \
-v "$HOST_SNAPSHOT_DIR":/src/snapshot:ro \
alpine sh -c '
rm -rf /data/snapshot &&
cp -r /src/snapshot /data/snapshot &&
chmod 755 /data/snapshot &&
chmod 644 /data/snapshot/* &&
chown -R 1000:1000 /data/snapshot
'
fi
# Use ${arr[@]+"${arr[@]}"} guards because `set -u` treats expansion
# of an empty array as unset. Without these guards the default (no
# --proxy, no extra args) invocation aborts with "PROFILES[@]: unbound
# variable".
CMD=(docker compose "${COMPOSE_ARGS[@]}" ${PROFILES[@]+"${PROFILES[@]}"} ${EXTRA_PROFILES[@]+"${EXTRA_PROFILES[@]}"} "$SUBCOMMAND" ${SUBCOMMAND_ARGS[@]+"${SUBCOMMAND_ARGS[@]}"})
echo "+ ${CMD[*]}"
exec "${CMD[@]}"