diff --git a/README.md b/README.md index a531dc8..08f6fc2 100644 --- a/README.md +++ b/README.md @@ -100,14 +100,14 @@ NETWORK_DIR=local-devnet ./spin-node.sh --node all --generateGenesis --aggregato After validator nodes are spun up, leanpoint is deployed so it can monitor them. Behavior depends on deployment mode: -- **Local deployment** (`NETWORK_DIR=local-devnet`, `deployment_mode: local`): Leanpoint runs **locally**. `sync-leanpoint-upstreams.sh` generates `upstreams.json` (with `--docker` so the container can reach host validators at `host.docker.internal`), writes it to `/data/upstreams.json`, pulls the latest image, and starts a local Docker container. UI at http://localhost:5555. The container is removed on Ctrl+C cleanup or when you run with `--stop`. +- **Local deployment** (`NETWORK_DIR=local-devnet`, `deployment_mode: local`): Leanpoint runs **locally**. `sync-leanpoint-upstreams.sh` generates `upstreams.json` (with `--docker` so the container can reach host validators at `host.docker.internal`), writes it to `/data/upstreams.json`, pulls the latest image, and starts a local Docker container. UI at **http://localhost:5555** (host port **`LEANPOINT_HOST_PORT`**, default **5555** — kept separate from Nemo’s default host port **5455**). The container is removed on Ctrl+C cleanup or when you run with `--stop`. - **Ansible/remote deployment**: Leanpoint is updated on the **tooling server**. The script rsyncs `upstreams.json` to the server, pulls the latest image there, and recreates the remote container. **What runs:** 1. `convert-validator-config.py` reads `validator-config.yaml` and generates `upstreams.json` (validator URLs for health checks). 2. `sync-leanpoint-upstreams.sh` either deploys leanpoint locally (local devnet) or syncs to the tooling server and recreates the remote container (Ansible). -**Remote defaults:** Tooling server `46.225.10.32`, user `root`, remote path `/etc/leanpoint/upstreams.json`, container name `leanpoint`. Override with env vars (see script header in `sync-leanpoint-upstreams.sh`). +**Remote defaults:** Tooling server `46.225.10.32`, user `root`, remote path `/etc/leanpoint/upstreams.json`, container name `leanpoint`, published port **5555→5555** (`LEANPOINT_HOST_PORT`, default **5555**). Override with env vars (see script header in `sync-leanpoint-upstreams.sh`). **SSH key for remote sync:** When using Ansible deployment, the tooling server may require a specific SSH key. Pass `--sshKey ~/.ssh/id_ed25519_github` (or `--private-key`) so the sync can succeed. @@ -124,6 +124,22 @@ python3 convert-validator-config.py local-devnet/genesis/validator-config.yaml u Requires Python 3 and PyYAML (`pip install pyyaml`). +### Nemo (block explorer) on the tooling server + +After validators are up, **Nemo** (Lean consensus block/slot explorer; image `0xpartha/nemo:latest`) can run on the same **tooling server** as leanpoint (`46.225.10.32` by default). **Ports:** leanpoint uses host **5555** by default; Nemo publishes host **5455** → container **5053** by default — they do not overlap. If you change either port, set **`NEMO_HOST_PORT`** and **`LEANPOINT_HOST_PORT`** so they stay different; `sync-nemo-tooling.sh` exits with an error if they match. **HTTPS / nginx:** see [`docs/tooling-server-nemo-nginx.md`](docs/tooling-server-nemo-nginx.md) and [`tooling/nginx-nemo.conf.example`](tooling/nginx-nemo.conf.example). + +It uses **`LEAN_API_URL`**: a comma-separated list of `http://:` for **every** entry in `validator-config.yaml` (same IPs/ports as the devnet HTTP APIs). Rows with **empty `enrFields.ip`** are skipped (e.g. placeholder nodes until an IP is set). + +- **Ansible deploy:** `sync-nemo-tooling.sh` writes `/etc/nemo/nemo.env`, runs **`docker pull`** on **`NEMO_IMAGE`** (default `0xpartha/nemo:latest`), then **`docker run --pull=always`** and recreates the `nemo` container. The SQLite data dir is **cleared only when `spin-node.sh` is run with `--generateGenesis`** (or when you set **`NEMO_RESET_DB=1`** manually). Otherwise the existing DB under **`/opt/nemo/data`** is reused. When **`NEMO_IMAGE`** is a **multi-arch** manifest that lists the host CPU (`linux/arm64` or `linux/amd64`), the script passes **`--platform`** for that arch so Docker pulls the matching variant (no platform-mismatch warning). For **single-arch** tags, it omits **`--platform`** so pull/run still succeed. Optional **`NEMO_DOCKER_PLATFORM`** overrides the detected platform when the manifest includes it. +- **Local devnet:** Same pull + `--pull=always` behavior; Nemo runs in Docker with `host.docker.internal` and data under `/data/nemo-data` (wiped only with **`--generateGenesis`**, same as remote). + +UI: `http://:5455` (override with `NEMO_HOST_PORT`). Skip with **`--skip-nemo`** or **`NEMO_SYNC_DISABLED=1`**. Env vars: see `sync-nemo-tooling.sh`. + +```sh +# Print LEAN_API_URL for the current ansible devnet config +python3 convert-validator-config.py --print-lean-api-url ansible-devnet/genesis/validator-config.yaml +``` + ### Remote Observability Stack Every Ansible deployment automatically deploys an observability stack alongside each lean node on remote hosts. No additional flags are needed. @@ -706,6 +722,7 @@ This quickstart includes automated configuration parsing: - **Official Genesis Generation**: Uses PK's `eth-beacon-genesis` docker tool from [PR #36](https://github.com/ethpandaops/eth-beacon-genesis/pull/36) - **Leanpoint upstreams sync**: After nodes are spun up, `convert-validator-config.py` and `sync-leanpoint-upstreams.sh` generate `upstreams.json` from `validator-config.yaml`, rsync it to the tooling server, and restart the leanpoint container (see [Leanpoint upstreams sync](#leanpoint-upstreams-sync-tooling-server)) +- **Nemo tooling sync**: `sync-nemo-tooling.sh` builds `LEAN_API_URL` for all validators, deploys Nemo on the tooling server (or locally), and clears its SQLite data on each restart (see [Nemo (block explorer) on the tooling server](#nemo-block-explorer-on-the-tooling-server)) - **Complete File Set**: Generates `validators.yaml`, `nodes.yaml`, `genesis.json`, `genesis.ssz`, and `.key` files - **QUIC Port Detection**: Automatically extracts QUIC ports from `validator-config.yaml` using `yq` - **Node Detection**: Dynamically discovers available nodes from the validator configuration diff --git a/convert-validator-config.py b/convert-validator-config.py index 14bc96b..823ad5a 100644 --- a/convert-validator-config.py +++ b/convert-validator-config.py @@ -1,17 +1,23 @@ #!/usr/bin/env python3 """ -Convert validator-config.yaml to upstreams.json for leanpoint. +Convert validator-config.yaml to upstreams.json for leanpoint, +or emit Nemo LEAN_API_URL / env file. This script reads a validator-config.yaml file (used by lean-quickstart) and generates an upstreams.json file that leanpoint can use to monitor -multiple lean nodes. +multiple lean nodes. It can also build the comma-separated LEAN_API_URL +that Nemo expects (one base URL per validator, apiPort/httpPort). Usage: python3 convert-validator-config.py [validator-config.yaml] [output.json] [--docker] + python3 convert-validator-config.py --print-lean-api-url [validator-config.yaml] [--docker] + + python3 convert-validator-config.py --write-nemo-env [--docker] + Options: - --docker Use host.docker.internal so leanpoint running in Docker can - reach a devnet on the host (e.g. upstreams-local-docker.json). + --docker Use host.docker.internal so a container on the host can reach + validators on the host (local devnet + Docker). Examples: python3 convert-validator-config.py \\ @@ -62,8 +68,9 @@ def convert_validator_config( # Try to get IP from enrFields, default to localhost ip = "127.0.0.1" - if 'enrFields' in validator and 'ip' in validator['enrFields']: - ip = validator['enrFields']['ip'] + enr = validator.get('enrFields') + if isinstance(enr, dict) and enr.get('ip') is not None: + ip = enr['ip'] if docker_host: ip = "host.docker.internal" @@ -77,24 +84,121 @@ def convert_validator_config( } upstreams.append(upstream) - + output = {"upstreams": upstreams} - + with open(output_path, 'w') as f: json.dump(output, f, indent=2) - + print(f"✅ Converted {len(upstreams)} validators to {output_path}") print(f"\nGenerated upstreams:") for u in upstreams: print(f" - {u['name']}: {u['url']}{u['path']}") - + print(f"\n💡 To use: leanpoint --upstreams-config {output_path}") +def nemo_lean_api_url_string( + yaml_path: str, + base_port: int = 8081, + docker_host: bool = False, +) -> str: + """ + Comma-separated Lean HTTP API base URLs for Nemo (no path), one per validator. + + Skips validators with empty enrFields.ip when not using docker_host (e.g. placeholder ansible rows). + """ + with open(yaml_path, 'r') as f: + config = yaml.safe_load(f) + + if 'validators' not in config: + raise ValueError("No 'validators' key found in config") + + bases: list[str] = [] + for idx, validator in enumerate(config['validators']): + name = validator.get('name', f'validator_{idx}') + ip = "127.0.0.1" + enr = validator.get('enrFields') + if isinstance(enr, dict) and enr.get('ip') is not None: + ip = enr['ip'] + if docker_host: + ip = "host.docker.internal" + elif isinstance(ip, str) and not ip.strip(): + print( + f"Warning: skipping validator '{name}' for Nemo LEAN_API_URL: empty enrFields.ip", + file=sys.stderr, + ) + continue + + http_port = validator.get('apiPort') or validator.get('httpPort') or (base_port + idx) + bases.append(f"http://{ip}:{http_port}") + + if not bases: + raise ValueError( + "No validators with a usable IP for Nemo; assign enrFields.ip or use --docker for local." + ) + return ",".join(bases) + + +def write_nemo_env_file( + yaml_path: str, + env_out_path: str, + base_port: int = 8081, + docker_host: bool = False, +) -> None: + """Write a docker --env-file compatible file for Nemo (LEAN_API_URL + defaults).""" + url_string = nemo_lean_api_url_string(yaml_path, base_port=base_port, docker_host=docker_host) + if any(c in url_string for c in (' ', '"', '\n', '\\')): + escaped = url_string.replace("\\", "\\\\").replace('"', '\\"') + line = f'LEAN_API_URL="{escaped}"\n' + else: + line = f"LEAN_API_URL={url_string}\n" + with open(env_out_path, "w") as f: + f.write(line) + f.write("NEMO_PORT=5053\n") + f.write("NEMO_DB_PATH=/data/nemo.db\n") + f.write("SYNC_INTERVAL_SEC=4\n") + + def main(): - args = [a for a in sys.argv[1:] if a != "--docker"] + argv = [a for a in sys.argv[1:] if a != "--docker"] docker_host = "--docker" in sys.argv + if "--print-lean-api-url" in argv: + argv = [a for a in argv if a != "--print-lean-api-url"] + yaml_path = argv[0] if argv else "local-devnet/genesis/validator-config.yaml" + try: + print(nemo_lean_api_url_string(yaml_path, docker_host=docker_host)) + except (OSError, ValueError, yaml.YAMLError) as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + return + + if "--write-nemo-env" in argv: + i = argv.index("--write-nemo-env") + try: + env_out = argv[i + 1] + yaml_path = argv[i + 2] + except IndexError: + print( + "Usage: convert-validator-config.py --write-nemo-env " + " [--docker]", + file=sys.stderr, + ) + sys.exit(1) + # Remove consumed args so stray args don't confuse + rest = argv[:i] + argv[i + 3:] + if rest: + print(f"Warning: ignoring extra arguments: {rest}", file=sys.stderr) + try: + write_nemo_env_file(yaml_path, env_out, docker_host=docker_host) + print(f"✅ Wrote Nemo env file to {env_out}") + except (OSError, ValueError, yaml.YAMLError) as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + return + + args = argv if len(args) < 2: if len(args) == 0: print(__doc__) diff --git a/docs/tooling-server-nemo-nginx.md b/docs/tooling-server-nemo-nginx.md new file mode 100644 index 0000000..a6bb113 --- /dev/null +++ b/docs/tooling-server-nemo-nginx.md @@ -0,0 +1,27 @@ +# Nemo on the tooling server (port + nginx) + +## Port + +`sync-nemo-tooling.sh` publishes Nemo on the **host** using **`NEMO_HOST_PORT`** (default **5455**). The container still listens on **5053** inside the image (`-p ${NEMO_HOST_PORT}:5053`). + +After changing the default or env, redeploy Nemo (e.g. run `spin-node.sh` without `--skip-nemo`, or invoke `sync-nemo-tooling.sh` manually) so Docker recreates the `nemo` container with the new publish port. + +## nginx + +1. Copy the example site and edit `server_name` (and SSL stanzas if you use HTTPS): + + ```sh + sudo cp /path/to/lean-quickstart/tooling/nginx-nemo.conf.example /etc/nginx/sites-available/nemo + sudoedit /etc/nginx/sites-available/nemo + ``` + +2. Ensure the **`upstream`** `server` line matches **`NEMO_HOST_PORT`** (default `127.0.0.1:5455`). + +3. Enable and reload: + + ```sh + sudo ln -sf /etc/nginx/sites-available/nemo /etc/nginx/sites-enabled/ + sudo nginx -t && sudo systemctl reload nginx + ``` + +If you previously proxied to port **5053**, update that line to **5455** (or whatever you set for `NEMO_HOST_PORT`). diff --git a/parse-env.sh b/parse-env.sh index 7b5b4b6..2689be7 100755 --- a/parse-env.sh +++ b/parse-env.sh @@ -104,6 +104,10 @@ while [[ $# -gt 0 ]]; do skipLeanpoint=true shift ;; + --skip-nemo) + skipNemo=true + shift + ;; --prepare) prepareMode=true shift @@ -157,4 +161,5 @@ echo "coreDumps = ${coreDumps:-disabled}" echo "checkpointSyncUrl = ${checkpointSyncUrl:-}" echo "restartClient = ${restartClient:-}" echo "skipLeanpoint = ${skipLeanpoint:-false}" +echo "skipNemo = ${skipNemo:-false}" echo "dryRun = ${dryRun:-false}" diff --git a/spin-node.sh b/spin-node.sh index 1bc1b3c..dcd21ee 100755 --- a/spin-node.sh +++ b/spin-node.sh @@ -122,6 +122,7 @@ if [ -n "$prepareMode" ] && [ "$prepareMode" == "true" ]; then [ -n "$popupTerminal" ] && ignored_flags+=("--popupTerminal") [ -n "$dockerWithSudo" ] && ignored_flags+=("--dockerWithSudo") [ -n "$skipLeanpoint" ] && ignored_flags+=("--skip-leanpoint") + [ -n "$skipNemo" ] && ignored_flags+=("--skip-nemo") [ -n "$validatorConfig" ] && [ "$validatorConfig" != "genesis_bootnode" ] \ && ignored_flags+=("--validatorConfig") @@ -475,6 +476,14 @@ if [ "$deployment_mode" == "ansible" ]; then fi fi + if [ -z "$skipNemo" ]; then + _nemo_reset_db=0 + [ -n "$generateGenesis" ] && _nemo_reset_db=1 + if ! NEMO_RESET_DB="$_nemo_reset_db" "$scriptDir/sync-nemo-tooling.sh" "$validator_config_file" "$scriptDir" "$sshKeyFile" "$useRoot"; then + echo "Warning: Nemo tooling sync failed. Pass --sshKey if the tooling server requires it, or use --skip-nemo to skip." + fi + fi + # Ansible deployment succeeded, exit normally exit 0 fi @@ -527,8 +536,10 @@ if [ -n "$stopNodes" ] && [ "$stopNodes" == "true" ]; then # Stop local leanpoint container if running if [ -n "$dockerWithSudo" ]; then sudo docker rm -f leanpoint 2>/dev/null || echo " Container leanpoint not found or already stopped" + sudo docker rm -f nemo 2>/dev/null || echo " Container nemo not found or already stopped" else docker rm -f leanpoint 2>/dev/null || echo " Container leanpoint not found or already stopped" + docker rm -f nemo 2>/dev/null || echo " Container nemo not found or already stopped" fi echo "✅ Local nodes stopped successfully!" @@ -705,6 +716,18 @@ if [ -z "$skipLeanpoint" ]; then fi fi +# Nemo explorer: same tooling server (Ansible) or local Docker; DB reset only with --generateGenesis +local_nemo_deployed=0 +if [ -z "$skipNemo" ]; then + _nemo_reset_db=0 + [ -n "$generateGenesis" ] && _nemo_reset_db=1 + if NEMO_RESET_DB="$_nemo_reset_db" "$scriptDir/sync-nemo-tooling.sh" "$validator_config_file" "$scriptDir" "$sshKeyFile" "$useRoot" "$dataDir"; then + local_nemo_deployed=1 + else + echo "Warning: Nemo deploy failed. Pass --sshKey if needed, or --skip-nemo to skip." + fi +fi + container_names="${spin_nodes[*]}" process_ids="${spinned_pids[*]}" @@ -728,6 +751,12 @@ cleanup() { eval "$execCmd" 2>/dev/null || true fi + if [ "${local_nemo_deployed:-0}" = "1" ]; then + execCmd="docker rm -f nemo" + [ -n "$dockerWithSudo" ] && execCmd="sudo $execCmd" + eval "$execCmd" 2>/dev/null || true + fi + # try for process ids execCmd="kill -9 $process_ids" echo "$execCmd" diff --git a/sync-leanpoint-upstreams.sh b/sync-leanpoint-upstreams.sh index 8e1c48c..1313186 100755 --- a/sync-leanpoint-upstreams.sh +++ b/sync-leanpoint-upstreams.sh @@ -21,6 +21,8 @@ # REMOTE_UPSTREAMS_PATH Remote path for upstreams.json (default: /etc/leanpoint/upstreams.json) # LEANPOINT_CONTAINER Docker container name (default: leanpoint) # LEANPOINT_IMAGE Docker image to pull and run (default: 0xpartha/leanpoint:latest) +# LEANPOINT_HOST_PORT Host port published for leanpoint HTTP (default: 5555). +# NEMO_HOST_PORT Used only for clash check (default 5053); must differ from LEANPOINT_HOST_PORT. # LEANPOINT_SYNC_DISABLED Set to 1 to skip (e.g. when tooling server is not used) set -e @@ -37,6 +39,13 @@ LEANPOINT_DIR="${LEANPOINT_DIR:-$scriptDir}" REMOTE_UPSTREAMS_PATH="${REMOTE_UPSTREAMS_PATH:-/etc/leanpoint/upstreams.json}" LEANPOINT_CONTAINER="${LEANPOINT_CONTAINER:-leanpoint}" LEANPOINT_IMAGE="${LEANPOINT_IMAGE:-0xpartha/leanpoint:latest}" +LEANPOINT_HOST_PORT="${LEANPOINT_HOST_PORT:-5555}" +NEMO_HOST_PORT="${NEMO_HOST_PORT:-5053}" + +if [ "${LEANPOINT_HOST_PORT}" = "${NEMO_HOST_PORT}" ]; then + echo "Error: LEANPOINT_HOST_PORT (${LEANPOINT_HOST_PORT}) must not equal NEMO_HOST_PORT (Nemo also binds that host port)." >&2 + exit 1 +fi if [ "${LEANPOINT_SYNC_DISABLED:-0}" = "1" ]; then echo "Leanpoint sync disabled (LEANPOINT_SYNC_DISABLED=1), skipping." @@ -65,9 +74,10 @@ if [ -n "$local_data_dir" ]; then docker pull "$LEANPOINT_IMAGE" docker stop "$LEANPOINT_CONTAINER" 2>/dev/null || true docker rm "$LEANPOINT_CONTAINER" 2>/dev/null || true - docker run -d --name "$LEANPOINT_CONTAINER" --restart unless-stopped -p 5555:5555 \ + docker run -d --name "$LEANPOINT_CONTAINER" --restart unless-stopped \ + -p "${LEANPOINT_HOST_PORT}:5555" \ -v "$local_upstreams:/etc/leanpoint/upstreams.json:ro" "$LEANPOINT_IMAGE" - echo "Leanpoint deployed locally at http://localhost:5555 (upstreams: $local_upstreams)." + echo "Leanpoint deployed locally at http://localhost:${LEANPOINT_HOST_PORT} (upstreams: $local_upstreams)." exit 0 fi @@ -93,6 +103,6 @@ remote_dir=$(dirname "$REMOTE_UPSTREAMS_PATH") $ssh_cmd "$remote_target" "mkdir -p $remote_dir" rsync -e "$ssh_cmd" "$out_file" "${remote_target}:${REMOTE_UPSTREAMS_PATH}" -$ssh_cmd "$remote_target" "docker pull $LEANPOINT_IMAGE && docker stop $LEANPOINT_CONTAINER 2>/dev/null || true; docker rm $LEANPOINT_CONTAINER 2>/dev/null || true; docker run -d --name $LEANPOINT_CONTAINER --restart unless-stopped -p 5555:5555 -v $REMOTE_UPSTREAMS_PATH:/etc/leanpoint/upstreams.json:ro $LEANPOINT_IMAGE" +$ssh_cmd "$remote_target" "docker pull $LEANPOINT_IMAGE && docker stop $LEANPOINT_CONTAINER 2>/dev/null || true; docker rm $LEANPOINT_CONTAINER 2>/dev/null || true; docker run -d --name $LEANPOINT_CONTAINER --restart unless-stopped -p ${LEANPOINT_HOST_PORT}:5555 -v $REMOTE_UPSTREAMS_PATH:/etc/leanpoint/upstreams.json:ro $LEANPOINT_IMAGE" echo "Leanpoint upstreams synced to $TOOLING_SERVER, image $LEANPOINT_IMAGE pulled, container '$LEANPOINT_CONTAINER' recreated." diff --git a/sync-nemo-tooling.sh b/sync-nemo-tooling.sh new file mode 100755 index 0000000..5569145 --- /dev/null +++ b/sync-nemo-tooling.sh @@ -0,0 +1,197 @@ +#!/bin/bash +# sync-nemo-tooling.sh — Build LEAN_API_URL from validator-config.yaml (all validators + +# their apiPort/httpPort), deploy Nemo on the tooling server or locally. +# +# SQLite under the Nemo data dir is wiped only when NEMO_RESET_DB=1 (spin-node sets this when +# --generateGenesis is passed). Otherwise the existing DB is reused. +# Image: docker pull NEMO_IMAGE, then docker run --pull=always so :latest is refreshed from the registry. +# +# Usage: +# sync-nemo-tooling.sh [ssh_key_file] [use_root] [local_data_dir] +# +# - If local_data_dir (5th arg) is set: run Nemo in Docker locally with host.docker.internal +# (--docker URL generation). Data: /nemo-data. +# - Otherwise: rsync env file to tooling server, recreate container. +# +# Env (optional): +# TOOLING_SERVER (default: 46.225.10.32) +# TOOLING_SERVER_USER (default: root) +# LEANPOINT_DIR Path with convert-validator-config.py (default: script_dir) +# REMOTE_NEMO_ENV_PATH Remote env file (default: /etc/nemo/nemo.env) +# REMOTE_NEMO_DATA_DIR Remote SQLite host dir (default: /opt/nemo/data) +# NEMO_CONTAINER (default: nemo) +# NEMO_IMAGE (default: 0xpartha/nemo:latest) +# NEMO_SYNC_DISABLED Set to 1 to skip +# NEMO_HOST_PORT Host port published for Nemo HTTP (default: 5455; maps to container port 5053). +# Must differ from LEANPOINT_HOST_PORT (default 5555) on the same host. +# LEANPOINT_HOST_PORT Only used for clash check (default 5555); set if you override leanpoint's host port. +# NEMO_DOCKER_PLATFORM Optional. linux/arm64 or linux/amd64 for pull/run. If unset, derived from +# `uname -m` on the machine that runs Docker (local host or tooling server). +# --platform is only passed when the registry manifest lists that architecture +# (multi-arch index). Single-arch images omit it so pull/run still work; publish +# a multi-arch image (see nemo/docker-bake.hcl) to get native pulls without emulation. +# NEMO_RESET_DB If 1 or true: delete SQLite files in the Nemo data dir before start. +# If unset or 0: reuse existing DB. spin-node sets 1 only with --generateGenesis. + +set -e + +nemo_should_reset_db() { + case "${NEMO_RESET_DB:-0}" in + 1 | true | yes) return 0 ;; + *) return 1 ;; + esac +} + +# Map kernel machine name to Docker platform (multi-arch images, e.g. 0xpartha/nemo:latest). +nemo_docker_platform_from_uname() { + case "${1:-}" in + aarch64 | arm64) echo linux/arm64 ;; + x86_64 | amd64) echo linux/amd64 ;; + *) echo "" ;; + esac +} + +# True if manifest inspect shows a multi-arch index that includes this CPU architecture. +# Single-platform images (no "manifests" array) return false so we do not pass --platform +# (avoids "no matching manifest" when the tag is amd64-only). +nemo_image_manifest_includes_arch() { + local image="$1" plat="$2" + local arch="${plat#linux/}" + case "$arch" in aarch64) arch=arm64 ;; esac + local json + json=$(docker manifest inspect "$image" 2>/dev/null) || return 1 + echo "$json" | grep -q '"manifests"' || return 1 + echo "$json" | grep -qE "\"architecture\"[[:space:]]*:[[:space:]]*\"$arch\"" +} + +# Optional pull/run --platform only when the registry has a matching manifest (and platform is known). +nemo_docker_platform_args_for_machine() { + local machine="$1" + local plat="${NEMO_DOCKER_PLATFORM:-}" + if [ -z "$plat" ]; then + plat=$(nemo_docker_platform_from_uname "$machine") || true + fi + if [ -z "$plat" ]; then + return 0 + fi + if nemo_image_manifest_includes_arch "$NEMO_IMAGE" "$plat"; then + printf '%s' "--platform $plat" + fi +} + +validator_config_file="${1:?Usage: sync-nemo-tooling.sh [ssh_key_file] [use_root] [local_data_dir]}" +scriptDir="${2:?Usage: sync-nemo-tooling.sh [ssh_key_file] [use_root] [local_data_dir]}" +sshKeyFile="${3:-}" +useRoot="${4:-false}" +local_data_dir="${5:-}" + +TOOLING_SERVER="${TOOLING_SERVER:-46.225.10.32}" +TOOLING_SERVER_USER="${TOOLING_SERVER_USER:-root}" +LEANPOINT_DIR="${LEANPOINT_DIR:-$scriptDir}" +REMOTE_NEMO_ENV_PATH="${REMOTE_NEMO_ENV_PATH:-/etc/nemo/nemo.env}" +REMOTE_NEMO_DATA_DIR="${REMOTE_NEMO_DATA_DIR:-/opt/nemo/data}" +NEMO_CONTAINER="${NEMO_CONTAINER:-nemo}" +NEMO_IMAGE="${NEMO_IMAGE:-0xpartha/nemo:latest}" +NEMO_HOST_PORT="${NEMO_HOST_PORT:-5455}" +LEANPOINT_HOST_PORT="${LEANPOINT_HOST_PORT:-5555}" + +if [ "${NEMO_HOST_PORT}" = "${LEANPOINT_HOST_PORT}" ]; then + echo "Error: NEMO_HOST_PORT (${NEMO_HOST_PORT}) must not equal LEANPOINT_HOST_PORT (leanpoint also binds that port on the tooling host)." >&2 + exit 1 +fi + +if [ "${NEMO_SYNC_DISABLED:-0}" = "1" ]; then + echo "Nemo sync disabled (NEMO_SYNC_DISABLED=1), skipping." + exit 0 +fi + +convert_script="$LEANPOINT_DIR/convert-validator-config.py" +if [ ! -f "$convert_script" ]; then + echo "Warning: convert-validator-config.py not found at $convert_script, skipping Nemo sync." + exit 0 +fi + +if [ ! -f "$validator_config_file" ]; then + echo "Warning: validator config not found at $validator_config_file, skipping Nemo sync." + exit 0 +fi + +run_local_nemo_container() { + local env_file="$1" + local data_vol="$2" + local plat_args + plat_args=$(nemo_docker_platform_args_for_machine "$(uname -m)") + # Always fetch the current registry manifest for this tag (e.g. :latest), then run with --pull=always as a second guard. + # --platform is used only when the image is a multi-arch index that lists this host's arch (native pull, no mismatch warning). + # shellcheck disable=SC2086 + docker pull $plat_args "$NEMO_IMAGE" + docker stop "$NEMO_CONTAINER" 2>/dev/null || true + docker rm -f "$NEMO_CONTAINER" 2>/dev/null || true + # shellcheck disable=SC2086 + docker run -d --pull=always $plat_args --name "$NEMO_CONTAINER" --restart unless-stopped \ + -p "${NEMO_HOST_PORT}:5053" \ + --env-file "$env_file" \ + -v "$data_vol:/data" \ + --add-host=host.docker.internal:host-gateway \ + "$NEMO_IMAGE" +} + +# --- Local: Docker on this machine, validators on host --- +if [ -n "$local_data_dir" ]; then + mkdir -p "$local_data_dir" + nemo_data="$local_data_dir/nemo-data" + mkdir -p "$nemo_data" + if nemo_should_reset_db; then + rm -rf "${nemo_data:?}/"* + echo "Nemo: cleared local data dir (NEMO_RESET_DB=1)." + else + echo "Nemo: reusing local data dir ${nemo_data} (set NEMO_RESET_DB=1 to wipe)." + fi + env_local="$local_data_dir/nemo.env" + python3 "$convert_script" --write-nemo-env "$env_local" "$validator_config_file" --docker || { + echo "Error: Nemo env generation failed." + exit 1 + } + run_local_nemo_container "$env_local" "$nemo_data" || { + echo "Error: local Nemo container start failed." + exit 1 + } + echo "Nemo deployed locally at http://localhost:${NEMO_HOST_PORT} (LEAN_API_URL uses host.docker.internal + devnet ports)." + exit 0 +fi + +# --- Remote tooling server --- +remote_target="${TOOLING_SERVER_USER}@${TOOLING_SERVER}" +ssh_cmd="ssh -o StrictHostKeyChecking=no" +if [ -n "$sshKeyFile" ]; then + key_path="$sshKeyFile" + [[ "$key_path" == ~* ]] && key_path="${key_path/#\~/$HOME}" + if [ -f "$key_path" ]; then + ssh_cmd="ssh -i $key_path -o StrictHostKeyChecking=no" + fi +fi + +out_env=$(mktemp) +trap 'rm -f "$out_env"' EXIT +python3 "$convert_script" --write-nemo-env "$out_env" "$validator_config_file" || { + echo "Error: Nemo env generation failed." + exit 1 +} + +remote_env_dir=$(dirname "$REMOTE_NEMO_ENV_PATH") +if nemo_should_reset_db; then + $ssh_cmd "$remote_target" "mkdir -p $remote_env_dir $REMOTE_NEMO_DATA_DIR && rm -rf ${REMOTE_NEMO_DATA_DIR}/*" + echo "Nemo: cleared remote data dir on $TOOLING_SERVER (NEMO_RESET_DB=1)." +else + $ssh_cmd "$remote_target" "mkdir -p $remote_env_dir $REMOTE_NEMO_DATA_DIR" + echo "Nemo: reusing remote data dir $REMOTE_NEMO_DATA_DIR (set NEMO_RESET_DB=1 to wipe)." +fi +rsync -e "$ssh_cmd" "$out_env" "${remote_target}:${REMOTE_NEMO_ENV_PATH}" + +# Remote Docker: match tooling host arch when the registry publishes that variant in a multi-arch manifest. +remote_uname=$($ssh_cmd "$remote_target" "uname -m" | tr -d '\r') +remote_plat_args=$(nemo_docker_platform_args_for_machine "$remote_uname") + +$ssh_cmd "$remote_target" "docker pull $remote_plat_args $NEMO_IMAGE && docker stop $NEMO_CONTAINER 2>/dev/null || true; docker rm -f $NEMO_CONTAINER 2>/dev/null || true; docker run -d --pull=always $remote_plat_args --name $NEMO_CONTAINER --restart unless-stopped -p ${NEMO_HOST_PORT}:5053 --env-file $REMOTE_NEMO_ENV_PATH -v $REMOTE_NEMO_DATA_DIR:/data $NEMO_IMAGE" + +echo "Nemo deployed on $TOOLING_SERVER at port ${NEMO_HOST_PORT} (data $REMOTE_NEMO_DATA_DIR, image $NEMO_IMAGE)." diff --git a/tooling/nginx-nemo.conf.example b/tooling/nginx-nemo.conf.example new file mode 100644 index 0000000..7d72058 --- /dev/null +++ b/tooling/nginx-nemo.conf.example @@ -0,0 +1,53 @@ +# Example nginx site for Nemo on the lean-quickstart tooling server. +# Default Docker publish: host 127.0.0.1:5455 -> container :5053 (see sync-nemo-tooling.sh NEMO_HOST_PORT). +# +# Install (typical Debian/Ubuntu): +# sudo cp tooling/nginx-nemo.conf.example /etc/nginx/sites-available/nemo +# sudo ln -sf /etc/nginx/sites-available/nemo /etc/nginx/sites-enabled/ +# sudo nginx -t && sudo systemctl reload nginx +# +# Replace server_name and TLS paths. Obtain certs (e.g. certbot) before enabling listen 443. + +upstream nemo_backend { + server 127.0.0.1:5455; + keepalive 8; +} + +server { + listen 80; + listen [::]:80; + server_name nemo.example.com; + + location / { + proxy_pass http://nemo_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 3600s; + } +} + +# server { +# listen 443 ssl http2; +# listen [::]:443 ssl http2; +# server_name nemo.example.com; +# +# ssl_certificate /etc/letsencrypt/live/nemo.example.com/fullchain.pem; +# ssl_certificate_key /etc/letsencrypt/live/nemo.example.com/privkey.pem; +# +# location / { +# proxy_pass http://nemo_backend; +# proxy_http_version 1.1; +# proxy_set_header Host $host; +# proxy_set_header X-Real-IP $remote_addr; +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# proxy_set_header X-Forwarded-Proto $scheme; +# proxy_set_header Upgrade $http_upgrade; +# proxy_set_header Connection "upgrade"; +# proxy_read_timeout 3600s; +# } +# }