#!/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[@]}"