#!/usr/bin/env bash set -euo pipefail DOCKER_BIN="${DOCKER_BIN:-/usr/local/bin/docker}" PLATFORM_DIR="${PLATFORM_DIR:-/volume1/docker/nodedc-platform/platform}" ENV_FILE="${ENV_FILE:-${PLATFORM_DIR}/${NODEDC_SYNOLOGY_ENV_FILE:-.env.synology}}" COMPOSE_FILE="${COMPOSE_FILE:-${PLATFORM_DIR}/docker-compose.platform-http.yml}" HUB_DIR="${HUB_DIR:-${PLATFORM_DIR}/ai-workspace-hub}" RELAY_ID="${AI_WORKSPACE_ASSISTANT_ACTION_RELAY_ID:-local-dev}" PUBLIC_VERIFY_BASE_URL="${AI_WORKSPACE_HUB_VERIFY_BASE_URL:-https://ai-hub.nodedc.ru}" VERIFY_PUBLIC="${VERIFY_PUBLIC:-1}" if [[ ! -x "${DOCKER_BIN}" ]]; then DOCKER_BIN="$(command -v docker)" fi read_env_value() { local file="$1" local key="$2" awk -F= -v key="$key" ' $0 ~ "^[[:space:]]*#" { next } $1 == key { value = substr($0, index($0, "=") + 1) gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) gsub(/^"|"$/, "", value) print value exit } ' "$file" } require_file() { local path="$1" local label="$2" if [[ ! -f "${path}" ]]; then echo "${label} missing: ${path}" >&2 exit 1 fi } require_dir() { local path="$1" local label="$2" if [[ ! -d "${path}" ]]; then echo "${label} missing: ${path}" >&2 exit 1 fi } show_container_debug() { local container_id="$1" echo "== ai-workspace-hub debug ==" "${DOCKER_BIN}" ps --filter "id=${container_id}" --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' || true echo "-- recent logs --" "${DOCKER_BIN}" logs --tail 120 "${container_id}" || true } check_container_health() { local container_id="$1" "${DOCKER_BIN}" exec "${container_id}" node -e ' fetch("http://127.0.0.1:18081/healthz") .then(async (response) => { const body = await response.text(); console.log(body); process.exit(response.ok ? 0 : 1); }) .catch(() => process.exit(2)); ' } check_container_relay_route() { local container_id="$1" "${DOCKER_BIN}" exec \ -e RELAY_ID="${RELAY_ID}" \ -e RELAY_TOKEN="${relay_token}" \ "${container_id}" \ node -e ' const relayId = encodeURIComponent(process.env.RELAY_ID || "local-dev"); const token = process.env.RELAY_TOKEN || ""; fetch(`http://127.0.0.1:18081/api/ai-workspace/hub/v1/assistant-relays/${relayId}/poll`, { method: "POST", headers: { authorization: `Bearer ${token}`, accept: "application/json", "content-type": "application/json", }, body: JSON.stringify({ limit: 1, timeoutMs: 1 }), }) .then(async (response) => { const text = await response.text(); if (response.status !== 200) { console.error(`relay route failed: status=${response.status} body=${text.slice(0, 300)}`); process.exit(1); } let payload = {}; try { payload = JSON.parse(text); } catch {} console.log(JSON.stringify({ ok: payload.ok === true, relayId: payload.relayId, calls: Array.isArray(payload.calls) ? payload.calls.length : null, })); }) .catch((error) => { console.error(error); process.exit(2); }); ' } echo "== ai hub relay preflight ==" require_dir "${PLATFORM_DIR}" "Platform directory" require_dir "${HUB_DIR}" "AI Workspace Hub source directory" require_file "${ENV_FILE}" "Synology env file" require_file "${COMPOSE_FILE}" "Synology compose file" require_file "${HUB_DIR}/Dockerfile" "AI Workspace Hub Dockerfile" require_file "${HUB_DIR}/package.json" "AI Workspace Hub package.json" require_file "${HUB_DIR}/src/server.mjs" "AI Workspace Hub server" hub_token="${AI_WORKSPACE_HUB_TOKEN:-$(read_env_value "${ENV_FILE}" AI_WORKSPACE_HUB_TOKEN || true)}" internal_token="${NODEDC_INTERNAL_ACCESS_TOKEN:-$(read_env_value "${ENV_FILE}" NODEDC_INTERNAL_ACCESS_TOKEN || true)}" relay_token="${hub_token:-${internal_token}}" if [[ -z "${relay_token}" ]]; then echo "No AI_WORKSPACE_HUB_TOKEN or NODEDC_INTERNAL_ACCESS_TOKEN found for relay verification." >&2 exit 1 fi cd "${PLATFORM_DIR}" if ! "${DOCKER_BIN}" compose --env-file "${ENV_FILE}" -f "${COMPOSE_FILE}" config --services \ | grep -qx 'ai-workspace-hub'; then echo "Compose service ai-workspace-hub is not available." >&2 exit 1 fi echo "== build ai-workspace-hub only ==" "${DOCKER_BIN}" compose \ --env-file "${ENV_FILE}" \ -f "${COMPOSE_FILE}" \ build ai-workspace-hub echo "== recreate ai-workspace-hub only ==" "${DOCKER_BIN}" compose \ --env-file "${ENV_FILE}" \ -f "${COMPOSE_FILE}" \ up -d --no-deps --force-recreate ai-workspace-hub container_id="$( "${DOCKER_BIN}" compose \ --env-file "${ENV_FILE}" \ -f "${COMPOSE_FILE}" \ ps -q ai-workspace-hub )" if [[ -z "${container_id}" ]]; then echo "Cannot resolve ai-workspace-hub container id after apply." >&2 exit 1 fi echo "== container health ==" health_ok=0 for attempt in $(seq 1 30); do if check_container_health "${container_id}"; then health_ok=1 break fi echo "ai-workspace-hub-not-ready attempt=${attempt}/30" sleep 2 done if [[ "${health_ok}" != "1" ]]; then show_container_debug "${container_id}" exit 1 fi echo "== container relay route ==" relay_ok=0 for attempt in $(seq 1 10); do if check_container_relay_route "${container_id}"; then relay_ok=1 break fi echo "ai-workspace-hub-relay-not-ready attempt=${attempt}/10" sleep 2 done if [[ "${relay_ok}" != "1" ]]; then show_container_debug "${container_id}" exit 1 fi if [[ "${VERIFY_PUBLIC}" == "1" ]]; then if command -v curl >/dev/null 2>&1; then echo "== public relay route ==" public_body="$(mktemp -t ai-hub-relay-public.XXXXXX)" public_status="$( curl -k -sS -m 20 \ -o "${public_body}" \ -w '%{http_code}' \ -X POST "${PUBLIC_VERIFY_BASE_URL%/}/api/ai-workspace/hub/v1/assistant-relays/${RELAY_ID}/poll" \ -H "Authorization: Bearer ${relay_token}" \ -H 'Accept: application/json' \ -H 'Content-Type: application/json' \ --data '{"limit":1,"timeoutMs":1}' )" if [[ "${public_status}" != "200" ]]; then echo "public relay route failed: status=${public_status}" >&2 head -c 300 "${public_body}" >&2 || true echo >&2 rm -f "${public_body}" exit 1 fi rm -f "${public_body}" echo "public-relay-route-ok" else echo "curl is not available; skipped public relay route check." fi fi echo "ai-hub-relay-apply-ok"