#!/usr/bin/env bash set -Eeuo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ENV_FILE="${ROOT_DIR}/.env" ENV_EXAMPLE="${ROOT_DIR}/.env.example" COMPOSE_FILE="${ROOT_DIR}/compose.yaml" VERSIONS_FILE="${ROOT_DIR}/state/hermes/.last-known-good.env" if [[ -f "$ENV_FILE" ]]; then set -a # shellcheck disable=SC1090 source "$ENV_FILE" set +a fi PROJECT_NAME="${COMPOSE_PROJECT_NAME:-alan}" HERMES_DATA_DIR="${HERMES_DATA_DIR:-./state/hermes}" HERMES_BACKUP_DIR="${HERMES_BACKUP_DIR:-./backups/hermes}" HERMES_API_PORT="${HERMES_API_PORT:-18642}" HERMES_DASHBOARD_PORT="${HERMES_DASHBOARD_PORT:-19119}" AGENTS_SHARED_NETWORK="${AGENTS_SHARED_NETWORK:-alan_agents_shared}" BR0_DOCKER_NETWORK="${BR0_DOCKER_NETWORK:-br0_lan}" BR0_PARENT="${BR0_PARENT:-br0}" BR0_SUBNET="${BR0_SUBNET:-}" BR0_GATEWAY="${BR0_GATEWAY:-}" BR0_IP_RANGE="${BR0_IP_RANGE:-}" BR0_HOST_SHIM_CIDR="${BR0_HOST_SHIM_CIDR:-}" BR0_HOST_SHIM_IFACE="${BR0_HOST_SHIM_IFACE:-${BR0_DOCKER_NETWORK}-shim}" HERMES_IMAGE="${HERMES_IMAGE:-nousresearch/hermes-agent}" HERMES_TAG="${HERMES_TAG:-latest}" HEALTHCHECK_URL="http://127.0.0.1:${HERMES_API_PORT}/health" compose() { docker compose --project-name "$PROJECT_NAME" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@" } require_file() { local path="$1" [[ -f "$path" ]] || { echo "missing file: $path" >&2; exit 1; } } ensure_env() { if [[ ! -f "$ENV_FILE" ]]; then cp "$ENV_EXAMPLE" "$ENV_FILE" echo "[OK] .env created from .env.example" fi } ensure_dirs() { mkdir -p "$ROOT_DIR/state/hermes" "$ROOT_DIR/backups/hermes" } ensure_networks() { if ! docker network inspect "$AGENTS_SHARED_NETWORK" >/dev/null 2>&1; then docker network create "$AGENTS_SHARED_NETWORK" >/dev/null echo "[OK] created docker network ${AGENTS_SHARED_NETWORK}" fi if ! docker network inspect "$BR0_DOCKER_NETWORK" >/dev/null 2>&1; then echo "[WARN] missing external br0 network ${BR0_DOCKER_NETWORK}" echo "[HINT] run: ./scripts/alanctl net-create-br0" fi } require_docker() { command -v docker >/dev/null 2>&1 || { echo "docker not found" >&2; exit 1; } docker info >/dev/null 2>&1 || { echo "docker daemon not reachable" >&2; exit 1; } docker compose version >/dev/null 2>&1 || { echo "docker compose plugin not available" >&2; exit 1; } } port_in_use() { local port="$1" ss -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "(^|:)${port}$" } check_ports() { local fail=0 for p in "$HERMES_API_PORT" "$HERMES_DASHBOARD_PORT"; do if port_in_use "$p"; then echo "[WARN] host port ${p} is already in use" fail=1 fi done return "$fail" } show_openclaw_hint() { if docker ps --format '{{.Names}}' | grep -Eqi 'openclaw'; then echo "[INFO] OpenClaw container(s) detected. Shared network stays isolated by default; only explicit interconnects should use ${AGENTS_SHARED_NETWORK}." fi } show_br0_hint() { if ! ip link show "$BR0_PARENT" >/dev/null 2>&1; then echo "[WARN] host bridge ${BR0_PARENT} not found" fi if docker network inspect "$BR0_DOCKER_NETWORK" >/dev/null 2>&1; then echo "[OK] external br0 Docker network available: ${BR0_DOCKER_NETWORK}" else echo "[WARN] external br0 Docker network missing: ${BR0_DOCKER_NETWORK}" fi } write_last_known_good() { mkdir -p "$(dirname "$VERSIONS_FILE")" cat > "$VERSIONS_FILE" </dev/null 2>&1; then echo "[OK] Hermes healthy on ${HEALTHCHECK_URL}" return 0 fi if (( $(date +%s) - started >= timeout )); then echo "[ERROR] Hermes health check timed out after ${timeout}s" >&2 return 1 fi sleep 3 done } set_env_value() { local key="$1" local value="$2" ensure_env if grep -qE "^${key}=" "$ENV_FILE"; then sed -i "s|^${key}=.*|${key}=${value}|" "$ENV_FILE" else printf '%s=%s\n' "$key" "$value" >> "$ENV_FILE" fi } current_image_ref() { echo "${HERMES_IMAGE}:${HERMES_TAG}" } cmd_bootstrap() { require_docker ensure_env ensure_dirs ensure_networks check_ports || true show_openclaw_hint show_br0_hint echo "[OK] bootstrap complete" } cmd_init_hermes() { require_docker ensure_env ensure_dirs echo "[INFO] starting Hermes setup wizard" docker run -it --rm \ --network "$BR0_DOCKER_NETWORK" \ -v "${ROOT_DIR}/state/hermes:/opt/data" \ "${HERMES_IMAGE}:${HERMES_TAG}" setup } cmd_validate() { require_docker ensure_env ensure_dirs ensure_networks echo "[INFO] validating compose" compose config >/dev/null check_ports || true show_br0_hint echo "[OK] compose config valid" } cmd_up() { require_docker ensure_env ensure_dirs ensure_networks if ! docker network inspect "$BR0_DOCKER_NETWORK" >/dev/null 2>&1; then echo "missing external br0 Docker network: ${BR0_DOCKER_NETWORK}" >&2 echo "create it with: ./scripts/alanctl net-create-br0" >&2 exit 1 fi check_ports || true local args=(up -d hermes) if [[ "${1:-}" == "--dashboard" ]]; then args=(--profile dashboard up -d hermes dashboard) fi compose "${args[@]}" health_wait 120 write_last_known_good } cmd_down() { require_docker ensure_env compose down } cmd_restart() { require_docker ensure_env compose restart hermes health_wait 120 } cmd_status() { require_docker ensure_env compose ps echo docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | awk 'NR==1 || /alan|openclaw/' if [[ -f "$VERSIONS_FILE" ]]; then echo echo "[INFO] last known good: $(read_last_known_good)" fi } cmd_logs() { require_docker ensure_env compose logs -f --tail=200 "${1:-hermes}" } cmd_shell() { require_docker ensure_env docker exec -it "${PROJECT_NAME}-hermes" bash } cmd_doctor() { require_docker ensure_env echo "[INFO] docker" docker version --format '{{.Server.Version}}' echo "[INFO] compose" docker compose version echo "[INFO] current image" echo "$(current_image_ref)" echo "[INFO] config check" compose config >/dev/null && echo "[OK] compose config" echo "[INFO] network" docker network inspect "$AGENTS_SHARED_NETWORK" --format '{{.Name}}' 2>/dev/null || echo "missing: ${AGENTS_SHARED_NETWORK}" docker network inspect "$BR0_DOCKER_NETWORK" --format '{{.Name}}' 2>/dev/null || echo "missing: ${BR0_DOCKER_NETWORK}" echo "[INFO] ports" check_ports || true echo "[INFO] host bridge" ip -brief link show "$BR0_PARENT" 2>/dev/null || echo "missing: ${BR0_PARENT}" echo "[INFO] health" if curl -fsS "$HEALTHCHECK_URL" >/dev/null 2>&1; then echo "[OK] Hermes API health endpoint reachable on ${HERMES_API_PORT}" else echo "[WARN] Hermes API health endpoint not reachable on ${HERMES_API_PORT}" fi } cmd_backup() { ensure_env ensure_dirs local src="${ROOT_DIR}/state/hermes" local dst_dir="${ROOT_DIR}/backups/hermes" local ts ts="$(date +%Y%m%d-%H%M%S)" local archive="${dst_dir}/hermes-${ts}.tar.gz" mkdir -p "$dst_dir" tar -C "$ROOT_DIR/state" -czf "$archive" hermes echo "$archive" } cmd_restore() { ensure_env ensure_dirs local archive="${1:-}" if [[ -z "$archive" || ! -f "$archive" ]]; then echo "usage: $0 restore /path/to/hermes-backup.tar.gz" >&2 exit 1 fi local restore_root="${ROOT_DIR}/state" local current_backup current_backup="$(cmd_backup)" echo "[INFO] current state backed up to ${current_backup}" rm -rf "${restore_root}/hermes" tar -C "$restore_root" -xzf "$archive" echo "[OK] restored ${archive}" } cmd_update_hermes() { require_docker ensure_env ensure_dirs local target_tag="${1:-$HERMES_TAG}" local previous_tag="$HERMES_TAG" local backup_file backup_file="$(cmd_backup)" echo "[INFO] backup created: ${backup_file}" echo "[INFO] updating Hermes from ${HERMES_IMAGE}:${previous_tag} to ${HERMES_IMAGE}:${target_tag}" set_env_value HERMES_TAG "$target_tag" # reload updated .env set -a # shellcheck disable=SC1090 source "$ENV_FILE" set +a docker pull "${HERMES_IMAGE}:${target_tag}" compose up -d --force-recreate hermes if health_wait 180; then write_last_known_good echo "[OK] update successful" return 0 fi echo "[WARN] update failed health check, rolling back to ${previous_tag}" set_env_value HERMES_TAG "$previous_tag" set -a # shellcheck disable=SC1090 source "$ENV_FILE" set +a docker pull "${HERMES_IMAGE}:${previous_tag}" || true compose up -d --force-recreate hermes health_wait 180 || true echo "[WARN] restored previous tag. If state migration caused issues, restore backup: ${backup_file}" return 1 } cmd_rollback_hermes() { require_docker ensure_env local target_ref target_ref="$(read_last_known_good)" local target_tag="${target_ref##*:}" echo "[INFO] rolling back to ${target_ref}" set_env_value HERMES_TAG "$target_tag" set -a # shellcheck disable=SC1090 source "$ENV_FILE" set +a docker pull "$target_ref" compose up -d --force-recreate hermes health_wait 180 } cmd_pin_hermes() { ensure_env local tag="${1:-}" if [[ -z "$tag" ]]; then echo "usage: $0 pin-hermes " >&2 exit 1 fi set_env_value HERMES_TAG "$tag" echo "[OK] pinned HERMES_TAG=${tag} in .env" } cmd_net_create_br0() { require_docker ensure_env if [[ -z "$BR0_SUBNET" || -z "$BR0_GATEWAY" || -z "$BR0_IP_RANGE" ]]; then echo "BR0_SUBNET, BR0_GATEWAY and BR0_IP_RANGE must be set in .env" >&2 exit 1 fi if ! ip link show "$BR0_PARENT" >/dev/null 2>&1; then echo "host bridge not found: ${BR0_PARENT}" >&2 exit 1 fi if docker network inspect "$BR0_DOCKER_NETWORK" >/dev/null 2>&1; then echo "[OK] ${BR0_DOCKER_NETWORK} already exists" return 0 fi docker network create -d macvlan \ --subnet "$BR0_SUBNET" \ --gateway "$BR0_GATEWAY" \ --ip-range "$BR0_IP_RANGE" \ -o parent="$BR0_PARENT" \ "$BR0_DOCKER_NETWORK" >/dev/null echo "[OK] created macvlan network ${BR0_DOCKER_NETWORK} on parent ${BR0_PARENT}" } cmd_net_create_shim() { if [[ -z "$BR0_HOST_SHIM_CIDR" ]]; then echo "BR0_HOST_SHIM_CIDR must be set in .env" >&2 exit 1 fi if ! docker network inspect "$BR0_DOCKER_NETWORK" >/dev/null 2>&1; then echo "missing Docker network: ${BR0_DOCKER_NETWORK}" >&2 exit 1 fi sudo ip link add "$BR0_HOST_SHIM_IFACE" link "$BR0_PARENT" type macvlan mode bridge 2>/dev/null || true sudo ip addr replace "$BR0_HOST_SHIM_CIDR" dev "$BR0_HOST_SHIM_IFACE" sudo ip link set "$BR0_HOST_SHIM_IFACE" up if [[ -n "$BR0_IP_RANGE" ]]; then sudo ip route replace "$BR0_IP_RANGE" dev "$BR0_HOST_SHIM_IFACE" fi echo "[OK] host shim ready on ${BR0_HOST_SHIM_IFACE} (${BR0_HOST_SHIM_CIDR})" } usage() { cat < Commands: bootstrap Prepare directories, .env and Docker networks init-hermes Run Hermes setup wizard interactively validate Validate compose config and port availability up [--dashboard] Start Hermes, optionally with dashboard down Stop the stack restart Restart Hermes status Show compose and related container status logs [service] Tail logs (default: hermes) shell Open a shell inside the Hermes container doctor Health and environment checks backup Create a timestamped backup archive restore Restore Hermes state from backup archive update-hermes [tag] Backup, pull target tag, recreate service, verify health, auto-rollback on failure rollback-hermes Reapply the last known good image tag pin-hermes Persist a specific Hermes image tag in .env net-create-br0 Create the external macvlan Docker network on top of br0 net-create-shim Create optional host-side macvlan shim for host<->container access USAGE } main() { local cmd="${1:-}" shift || true case "$cmd" in bootstrap) cmd_bootstrap "$@" ;; init-hermes) cmd_init_hermes "$@" ;; validate) cmd_validate "$@" ;; up) cmd_up "$@" ;; down) cmd_down "$@" ;; restart) cmd_restart "$@" ;; status) cmd_status "$@" ;; logs) cmd_logs "$@" ;; shell) cmd_shell "$@" ;; doctor) cmd_doctor "$@" ;; backup) cmd_backup "$@" ;; restore) cmd_restore "$@" ;; update-hermes) cmd_update_hermes "$@" ;; rollback-hermes) cmd_rollback_hermes "$@" ;; pin-hermes) cmd_pin_hermes "$@" ;; net-create-br0) cmd_net_create_br0 "$@" ;; net-create-shim) cmd_net_create_shim "$@" ;; *) usage; [[ -n "$cmd" ]] && exit 1 ;; esac } main "$@"