Initial version of the Hermes self learning agent setup of JR IT Services.
This commit is contained in:
Executable
+455
@@ -0,0 +1,455 @@
|
||||
#!/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 "$@"
|
||||
Reference in New Issue
Block a user