#!/usr/bin/env bash #!/usr/bin/env bash # Quayel agent bootstrap — install Docker, nixpacks, dirs, then compose up # etcd + APISIX + qagent (public image: quayel/agent-agent-api). # # Usage: # sudo SERVER_TOKEN=... ./install.sh # curl -fsSL | sudo bash -s -- # # Bump VERSION whenever this script's behavior/config changes (qagent auto-update compares it). VERSION=1.0.0 set -euo pipefail INSTALL_DIR="${QUAYEL_INSTALL_DIR:-/opt/quayel}" NETWORK_NAME="${QUAYEL_NETWORK:-quayel-main-network}" COMPOSE_PROJECT="${COMPOSE_PROJECT_NAME:-quayel}" QAGENT_IMAGE="${QAGENT_IMAGE:-quayel/agent-agent-api:latest}" log() { printf '\n==> %s\n' "$*"; } ok() { printf ' OK %s\n' "$*"; } warn() { printf ' WARN %s\n' "$*" >&2; } die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } need_root() { if [[ "$(id -u)" -ne 0 ]]; then die "run as root (sudo ./install.sh)" fi } detect_os() { if [[ -f /etc/os-release ]]; then # shellcheck source=/dev/null . /etc/os-release OS_ID="${ID:-}" OS_VER="${VERSION_CODENAME:-}" else die "unsupported OS (need /etc/os-release)" fi case "$OS_ID" in ubuntu|debian) ;; *) warn "untested OS '$OS_ID' — continuing with Debian-style packages" ;; esac } install_host_packages() { log "Installing host packages" export DEBIAN_FRONTEND=noninteractive apt-get update -qq apt-get install -y -qq \ ca-certificates curl gnupg lsb-release \ openssl jq util-linux \ >/dev/null ok "base packages" } install_docker() { if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then ok "Docker already installed ($(docker --version))" return fi log "Installing Docker Engine + Compose plugin" install -m 0755 -d /etc/apt/keyrings if [[ ! -f /etc/apt/keyrings/docker.asc ]]; then curl -fsSL "https://download.docker.com/linux/${OS_ID}/gpg" -o /etc/apt/keyrings/docker.asc chmod a+r /etc/apt/keyrings/docker.asc fi arch="$(dpkg --print-architecture)" echo "deb [arch=${arch} signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/${OS_ID} ${OS_VER} stable" \ > /etc/apt/sources.list.d/docker.list apt-get update -qq apt-get install -y -qq \ docker-ce docker-ce-cli containerd.io \ docker-buildx-plugin docker-compose-plugin \ >/dev/null systemctl enable --now docker ok "Docker $(docker --version)" } install_nixpacks() { if command -v nixpacks >/dev/null 2>&1; then ok "nixpacks already installed ($(nixpacks --version 2>/dev/null | head -1))" return fi log "Installing nixpacks" curl -fsSL https://nixpacks.com/install.sh | bash -s -- -y if [[ ! -x /usr/local/bin/nixpacks && -x "$HOME/.local/bin/nixpacks" ]]; then ln -sf "$HOME/.local/bin/nixpacks" /usr/local/bin/nixpacks fi command -v nixpacks >/dev/null 2>&1 || die "nixpacks install failed" ok "nixpacks $(nixpacks --version 2>/dev/null | head -1)" } ensure_swarm_and_network() { log "Docker Swarm + network ${NETWORK_NAME}" if ! docker info 2>/dev/null | grep -q 'Swarm: active'; then ADV="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}' || true)" if [[ -n "${ADV}" ]]; then docker swarm init --advertise-addr "$ADV" >/dev/null || true else docker swarm init >/dev/null || true fi fi if docker info 2>/dev/null | grep -q 'Swarm: active'; then ok "swarm active" else warn "swarm not active — overlay network may fail; trying anyway" fi if docker network inspect "$NETWORK_NAME" >/dev/null 2>&1; then ok "network ${NETWORK_NAME} exists" else docker network create --driver overlay --attachable "$NETWORK_NAME" >/dev/null ok "created overlay network ${NETWORK_NAME}" fi } write_apisix_config() { cat > /etc/quayel/apisix/config.yaml <<'YAML' apisix: node_listen: - 9080 enable_admin: true enable_control: true show_upstream_status_in_response_header: true enable_http2: true dns_resolver: - "127.0.0.11" dns_resolver_valid: 5 resolver_timeout: 3 ssl: enable: true listen: - port: 9443 fallback_sni: "quayel-default" discovery: dns: servers: - "127.0.0.11:53" plugin_attr: proxy-cache: zones: - name: disk_cache_one memory_size: 50m disk_size: 1G disk_path: /var/lib/apisix/cache cache_levels: "1:2" deployment: admin: allow_admin: - 0.0.0.0/0 admin_key: - name: admin key: quayel-apisix-admin-key role: admin etcd: host: - "http://etcd:2379" prefix: "/apisix" timeout: 30 YAML } write_compose_file() { # Embedded compose — always written so a pasted install.sh is self-contained. # Never set ETCD_DATA_DIR / ALLOW_NONE_AUTHENTICATION — conflicts with --data-dir (etcd fatal). cat > "${INSTALL_DIR}/docker-compose.yml" <<'YAML' services: etcd: image: quay.io/coreos/etcd:v3.5.16 container_name: etcd restart: unless-stopped command: - /usr/local/bin/etcd - --data-dir=/etcd-data - --name=etcd0 - --listen-client-urls=http://0.0.0.0:2379 - --advertise-client-urls=http://etcd:2379 - --listen-peer-urls=http://0.0.0.0:2380 - --initial-advertise-peer-urls=http://etcd:2380 - --initial-cluster=etcd0=http://etcd:2380 - --initial-cluster-token=quayel-etcd - --initial-cluster-state=new volumes: - etcd-data:/etcd-data networks: - quayel-main-network healthcheck: test: ["CMD", "etcdctl", "endpoint", "health"] interval: 5s timeout: 5s retries: 12 start_period: 10s apisix: image: apache/apisix:3.11.0-debian container_name: apisix restart: unless-stopped depends_on: etcd: condition: service_healthy ports: - "80:9080" - "443:9443" - "9180:9180" volumes: - /etc/quayel/apisix/config.yaml:/usr/local/apisix/conf/config.yaml:ro - /var/lib/quayel/apisix-cache:/var/lib/apisix/cache networks: - quayel-main-network quayel-agent-api: image: ${QAGENT_IMAGE:-quayel/agent-agent-api:latest} container_name: quayel-agent-api restart: unless-stopped privileged: true pid: host depends_on: - apisix environment: NODE_ENV: production SERVER_TOKEN: ${SERVER_TOKEN:-} APISIX_ADMIN_URL: http://apisix:9180 APISIX_ADMIN_KEY: ${APISIX_ADMIN_KEY:-quayel-apisix-admin-key} QUAYEL_METRICS_HOST: ${QUAYEL_METRICS_HOST:-https://agent-api.quayel.com} INSTALL_SCRIPT_URL: ${INSTALL_SCRIPT_URL:-https://agent-api.quayel.com/install.sh} UPDATE_CHECK_INTERVAL_MS: ${UPDATE_CHECK_INTERVAL_MS:-} UPDATE_CHECK_DISABLED: ${UPDATE_CHECK_DISABLED:-} volumes: - /etc/quayel:/etc/quayel - /var/log/quayel:/var/log/quayel - /var/lib/quayel:/var/lib/quayel - /var/run/docker.sock:/var/run/docker.sock networks: - quayel-main-network volumes: etcd-data: networks: quayel-main-network: external: true YAML } ensure_dirs_and_files() { log "Quayel directories + config (no git clone — qagent from Docker Hub)" mkdir -p \ "$INSTALL_DIR" \ /etc/quayel/apisix \ /var/lib/quayel/{apisix-cache,logs,source,compose,ssl} \ /var/log/quayel SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Bad prior mounts sometimes created config.yaml as a directory. if [[ -d /etc/quayel/apisix/config.yaml ]]; then warn "/etc/quayel/apisix/config.yaml is a directory — removing" rm -rf /etc/quayel/apisix/config.yaml fi if [[ -f "${SCRIPT_DIR}/deploy/apisix/config.yaml" ]]; then cp -f "${SCRIPT_DIR}/deploy/apisix/config.yaml" /etc/quayel/apisix/config.yaml else write_apisix_config fi ok "/etc/quayel/apisix/config.yaml" # Always rewrite compose from this script (fixes old ETCD_DATA_DIR installs). write_compose_file ok "${INSTALL_DIR}/docker-compose.yml" # Agent JWT RS256 public key only (private key never installed). mkdir -p /etc/quayel/keys if [[ -f "${SCRIPT_DIR}/keys/agent-jwt-public.pem" ]]; then cp -f "${SCRIPT_DIR}/keys/agent-jwt-public.pem" /etc/quayel/keys/agent-jwt-public.pem else cat > /etc/quayel/keys/agent-jwt-public.pem <<'PEM' -----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8jbhyWrMedlR2r1HyziM 2k/mYp0osFmp1pR7vnIMEoKqR70Yof5VKxHQwyS3cPxWiEHTd3fSf+ljPxawxwib EjJY0G7sUttqhjYkaHy+OksT/pu+w63zJ9yqF0SiAi5SvhmWg3JmWafwkqfVGsGS TJU/DEKDTgU5V6fU3csonDfmuWfYeTDSXxibBvJmdUCME8CoREjeb723/drmlm6w XjQLsQqkqgP3srm652zAJhpa+FV1CKPukYUsynhS7r5FRM2I4saiJN/1nryXNzUu LJJih8vcveAwwBQ+AwkcavL0zK8Fr5PgmBa26xIbxjIixmbKxbIfbzbXT/cjICN0 WQIDAQAB -----END PUBLIC KEY----- PEM fi chmod 644 /etc/quayel/keys/agent-jwt-public.pem ok "agent JWT public key (RS256 / kid agent-v1)" if [[ -n "${SERVER_TOKEN:-}" ]]; then printf '%s\n' "$SERVER_TOKEN" > /etc/quayel/server_token chmod 600 /etc/quayel/server_token ok "SERVER_TOKEN saved" elif [[ -f /etc/quayel/server_token ]]; then SERVER_TOKEN="$(tr -d '[:space:]' < /etc/quayel/server_token)" export SERVER_TOKEN ok "loaded SERVER_TOKEN from /etc/quayel/server_token" else warn "SERVER_TOKEN not set — agent cloud sync will fail until you set it" fi umask 077 cat > "${INSTALL_DIR}/.env" </dev/null 2>&1 || true done docker compose pull docker compose up -d --force-recreate ok "compose up" log "Waiting for etcd healthy" local i=0 until docker exec etcd etcdctl endpoint health >/dev/null 2>&1; do i=$((i + 1)) if [[ "$i" -gt 30 ]]; then warn "etcd timed out — check: docker logs etcd" warn "if volume is corrupt: docker compose -f ${INSTALL_DIR}/docker-compose.yml down && docker volume rm ${COMPOSE_PROJECT}_etcd-data" break fi sleep 2 done if docker exec etcd etcdctl endpoint health >/dev/null 2>&1; then ok "etcd healthy" fi log "Waiting for APISIX admin" i=0 until curl -sf http://127.0.0.1:9180/apisix/admin/routes \ -H "X-API-KEY: ${APISIX_ADMIN_KEY:-quayel-apisix-admin-key}" >/dev/null 2>&1; do i=$((i + 1)) if [[ "$i" -gt 60 ]]; then warn "APISIX admin timed out — check: docker logs apisix" break fi sleep 2 done if curl -sf http://127.0.0.1:9180/apisix/admin/routes \ -H "X-API-KEY: ${APISIX_ADMIN_KEY:-quayel-apisix-admin-key}" >/dev/null 2>&1; then ok "APISIX admin on :9180" fi log "Waiting for agent health" i=0 until docker exec quayel-agent-api curl -sf http://127.0.0.1:4000/health >/dev/null 2>&1; do i=$((i + 1)) if [[ "$i" -gt 60 ]]; then warn "agent health timed out — check: docker logs quayel-agent-api" break fi sleep 2 done if docker exec quayel-agent-api curl -sf http://127.0.0.1:4000/health >/dev/null 2>&1; then ok "agent healthy on :4000" fi # Confirm agent can resolve/reach APISIX by Docker DNS. if docker exec quayel-agent-api curl -sf http://apisix:9180/apisix/admin/routes \ -H "X-API-KEY: ${APISIX_ADMIN_KEY:-quayel-apisix-admin-key}" >/dev/null 2>&1; then ok "agent → apisix:9180 reachable" else warn "agent cannot reach http://apisix:9180 — check docker network" docker network inspect "$NETWORK_NAME" --format '{{range .Containers}}{{.Name}} {{end}}' || true fi sleep 2 docker exec quayel-agent-api curl -sf -X POST http://127.0.0.1:4000/api/v1/gateway/reload >/dev/null 2>&1 \ && ok "gateway reloaded" \ || warn "gateway reload skipped (retries on agent restart)" } print_summary() { cat < /etc/quayel/install_version ok "install version ${VERSION} → /etc/quayel/install_version" print_summary } main "$@"