Files

456 lines
13 KiB
Bash
Executable File

#!/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" <<EOV
HERMES_IMAGE=${HERMES_IMAGE}
HERMES_TAG=${HERMES_TAG}
EOV
echo "[OK] recorded last known good image ${HERMES_IMAGE}:${HERMES_TAG}"
}
read_last_known_good() {
require_file "$VERSIONS_FILE"
# shellcheck disable=SC1090
source "$VERSIONS_FILE"
echo "${HERMES_IMAGE}:${HERMES_TAG}"
}
health_wait() {
local timeout="${1:-120}"
local started
started="$(date +%s)"
while true; do
if curl -fsS "$HEALTHCHECK_URL" >/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 <tag>" >&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 <<USAGE
Usage: $(basename "$0") <command>
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 <file> 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 <tag> 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 "$@"