light-heart-labs--dreamserver
9e8f1bbeed
Dashboard / frontend (push) Failing after 0s
Dashboard / api (push) Failing after 0s
Lint PowerShell / powershell-lint (ubuntu-latest) (push) Failing after 1s
Python Lint / Lint Python with Ruff (push) Failing after 1s
ShellCheck / Lint shell scripts (push) Failing after 1s
Matrix Smoke / linux-smoke (push) Failing after 1s
Matrix Smoke / distro: cachyos (push) Failing after 15s
Matrix Smoke / distro: linux-mint-21.3 (push) Failing after 15s
Matrix Smoke / distro: debian-12 (push) Failing after 5m21s
Matrix Smoke / distro: fedora-41 (push) Failing after 4m56s
Matrix Smoke / distro: ubuntu-24.04 (push) Failing after 2m13s
Matrix Smoke / distro: rocky-9 (push) Failing after 10m39s
Matrix Smoke / distro: manjaro (push) Failing after 12m11s
Matrix Smoke / distro: opensuse-tw (push) Failing after 11m53s
Matrix Smoke / distro: archlinux (push) Failing after 20m3s
Matrix Smoke / distro: ubuntu-22.04 (push) Failing after 13m49s
Validate .env Schema / tier-1-env-validation (push) Successful in 52s
Validate .env Schema / tier-2-env-validation (push) Successful in 44s
Validate .env Schema / tier-3-env-validation (push) Successful in 52s
Validate .env Schema / tier-4-env-validation (push) Successful in 51s
Validate Extensions Catalog / Check catalog is up-to-date (push) Failing after 9m47s
Secret Scan / Scan for secrets (push) Failing after 21m4s
Validate Docker Compose / Validate Docker Compose files (push) Has been cancelled
Python Type Check / Type check with mypy (push) Has been cancelled
Validate .env Schema / tier-0-env-validation (push) Has been cancelled
Test Linux / integration-smoke (push) Has been cancelled
Lint PowerShell / powershell-lint (windows-latest) (push) Has been cancelled
Matrix Smoke / macos-smoke (push) Has been cancelled
362 行
12 KiB
Bash
可执行文件
362 行
12 KiB
Bash
可执行文件
#!/usr/bin/env bash
|
|
# End-to-end smoke for ODS wrapping an existing Lemonade SDK runtime.
|
|
#
|
|
# Default mode starts a tiny OpenAI-compatible mock Lemonade server, renders the
|
|
# external-Lemonade LiteLLM config, starts LiteLLM through the real ODS
|
|
# compose overlay, and verifies a chat completion traverses the route.
|
|
#
|
|
# Real mode points at an already-running Lemonade SDK service:
|
|
# LEMONADE_E2E_URL=http://localhost:13305 \
|
|
# LEMONADE_E2E_MODEL=<model-from-/api/v1/models> \
|
|
# tests/fleet-external-lemonade-e2e.sh --real
|
|
|
|
set -euo pipefail
|
|
|
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
MODE="mock"
|
|
KEEP_WORK=false
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage: tests/fleet-external-lemonade-e2e.sh [--mock|--real] [--keep-work]
|
|
|
|
Options:
|
|
--mock Start a local mock Lemonade server container (default).
|
|
--real Use LEMONADE_E2E_URL as an already-running Lemonade SDK service.
|
|
--keep-work Keep the temporary copied ODS tree for debugging.
|
|
-h, --help Show this help.
|
|
|
|
Environment:
|
|
LEMONADE_E2E_URL Host-side Lemonade URL for --real. Default: http://localhost:13305
|
|
LEMONADE_E2E_MODEL Model id to request. Default: Qwen3-0.6B-GGUF
|
|
LEMONADE_E2E_MOCK_IMAGE Python image for --mock. Default: python:3.12-bookworm
|
|
LEMONADE_E2E_PORT Host port for --mock. Default: auto-selected free port
|
|
LEMONADE_E2E_LITELLM_PORT Host LiteLLM port. Default: auto-selected free port
|
|
EOF
|
|
}
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--mock) MODE="mock"; shift ;;
|
|
--real) MODE="real"; shift ;;
|
|
--keep-work) KEEP_WORK=true; shift ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) echo "[FAIL] unknown argument: $1" >&2; usage >&2; exit 1 ;;
|
|
esac
|
|
done
|
|
|
|
pass() { printf '[PASS] %s\n' "$1"; }
|
|
fail() { printf '[FAIL] %s\n' "$1" >&2; exit 1; }
|
|
note() { printf '[INFO] %s\n' "$1"; }
|
|
|
|
need() {
|
|
command -v "$1" >/dev/null 2>&1 || fail "$1 is required"
|
|
}
|
|
|
|
need docker
|
|
need curl
|
|
|
|
PY="${ODS_PYTHON_CMD:-}"
|
|
if [[ -z "$PY" ]]; then
|
|
if command -v python3 >/dev/null 2>&1; then
|
|
PY=python3
|
|
elif command -v python >/dev/null 2>&1; then
|
|
PY=python
|
|
else
|
|
fail "python is required"
|
|
fi
|
|
fi
|
|
|
|
free_port() {
|
|
"$PY" - <<'PY'
|
|
import socket
|
|
with socket.socket() as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
print(s.getsockname()[1])
|
|
PY
|
|
}
|
|
|
|
wait_url() {
|
|
local url="$1" label="$2" timeout="${3:-120}"
|
|
local start now
|
|
start="$(date +%s)"
|
|
while true; do
|
|
if curl -fsS "$url" >/dev/null 2>&1; then
|
|
pass "$label"
|
|
return 0
|
|
fi
|
|
now="$(date +%s)"
|
|
if (( now - start >= timeout )); then
|
|
echo "[DEBUG] timed out waiting for $url" >&2
|
|
return 1
|
|
fi
|
|
sleep 2
|
|
done
|
|
}
|
|
|
|
json_get_first_model() {
|
|
"$PY" - "$1" <<'PY'
|
|
import json, sys
|
|
payload = json.loads(sys.argv[1])
|
|
data = payload.get("data") or []
|
|
print((data[0] or {}).get("id", "") if data else "")
|
|
PY
|
|
}
|
|
|
|
host_to_container_base() {
|
|
local base="$1"
|
|
case "$base" in
|
|
http://localhost:*) printf '%s\n' "${base/http:\/\/localhost:/http:\/\/host.docker.internal:}" ;;
|
|
http://127.0.0.1:*) printf '%s\n' "${base/http:\/\/127.0.0.1:/http:\/\/host.docker.internal:}" ;;
|
|
*) printf '%s\n' "$base" ;;
|
|
esac
|
|
}
|
|
|
|
strip_api_suffix() {
|
|
local value="${1%/}"
|
|
case "$value" in
|
|
*/api/v1) value="${value%/api/v1}" ;;
|
|
*/v1) value="${value%/v1}" ;;
|
|
*/api) value="${value%/api}" ;;
|
|
esac
|
|
printf '%s\n' "$value"
|
|
}
|
|
|
|
suffix="$(date +%s)-$$"
|
|
work_dir="$(mktemp -d "${TMPDIR:-/tmp}/ods-external-lemonade-e2e.XXXXXX")"
|
|
runtime_dir="$work_dir/ods"
|
|
mock_container="ods-lemonade-mock-$suffix"
|
|
litellm_container="ods-litellm-e2e-$suffix"
|
|
compose_project="ods-external-lemonade-e2e-$suffix"
|
|
litellm_override_file="e2e-litellm-container.override.yml"
|
|
ods_network="ods-network"
|
|
|
|
ensure_ods_network() {
|
|
docker network inspect "$ods_network" >/dev/null 2>&1 \
|
|
|| docker network create "$ods_network" >/dev/null
|
|
}
|
|
|
|
cleanup() {
|
|
set +e
|
|
if [[ -d "$runtime_dir" ]]; then
|
|
(cd "$runtime_dir" && docker compose \
|
|
-p "$compose_project" \
|
|
-f docker-compose.base.yml \
|
|
-f docker-compose.cloud.yml \
|
|
-f docker-compose.lemonade-external.yml \
|
|
-f extensions/services/litellm/compose.yaml \
|
|
-f "$litellm_override_file" \
|
|
down --remove-orphans >/dev/null 2>&1)
|
|
fi
|
|
docker rm -f "$mock_container" >/dev/null 2>&1
|
|
if [[ "$KEEP_WORK" != "true" ]]; then
|
|
rm -rf "$work_dir"
|
|
else
|
|
note "Kept work dir: $work_dir"
|
|
fi
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
note "Preparing temporary ODS tree at $runtime_dir"
|
|
mkdir -p "$runtime_dir"
|
|
(cd "$ROOT_DIR" && tar --exclude='.git' --exclude='.pytest_cache' -cf - .) | (cd "$runtime_dir" && tar -xf -)
|
|
|
|
model="${LEMONADE_E2E_MODEL:-Qwen3-0.6B-GGUF}"
|
|
lemonade_key="${LEMONADE_E2E_API_KEY:-sk-e2e-lemonade}"
|
|
litellm_key="${LEMONADE_E2E_LITELLM_KEY:-sk-e2e-litellm}"
|
|
api_path="/api/v1"
|
|
|
|
if [[ "$MODE" == "mock" ]]; then
|
|
lemonade_port="${LEMONADE_E2E_PORT:-$(free_port)}"
|
|
host_base="http://127.0.0.1:${lemonade_port}"
|
|
container_base="http://host.docker.internal:${lemonade_port}"
|
|
note "Starting mock Lemonade server on $host_base"
|
|
mock_publish="127.0.0.1:${lemonade_port}:13305"
|
|
if [[ "$(uname -s)" == "Linux" ]]; then
|
|
# On native Linux, containers reach host services through the bridge
|
|
# gateway, so a host service bound only to loopback is not reachable.
|
|
mock_publish="${lemonade_port}:13305"
|
|
fi
|
|
read -r -d '' mock_code <<'PY' || true
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
import json
|
|
|
|
MODEL = "Qwen3-0.6B-GGUF"
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def log_message(self, *_args):
|
|
return
|
|
|
|
def _send(self, status, payload):
|
|
body = json.dumps(payload).encode()
|
|
self.send_response(status)
|
|
self.send_header("content-type", "application/json")
|
|
self.send_header("content-length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def do_GET(self):
|
|
if self.path in {"/api/v1/health", "/health"}:
|
|
self._send(200, {"status": "ok"})
|
|
elif self.path == "/api/v1/models":
|
|
self._send(200, {"object": "list", "data": [{"id": MODEL, "object": "model", "owned_by": "ods-e2e"}]})
|
|
else:
|
|
self._send(404, {"error": self.path})
|
|
|
|
def do_POST(self):
|
|
length = int(self.headers.get("content-length", "0") or 0)
|
|
if length:
|
|
self.rfile.read(length)
|
|
if self.path == "/api/v1/chat/completions":
|
|
self._send(200, {
|
|
"id": "chatcmpl-ods-e2e",
|
|
"object": "chat.completion",
|
|
"model": MODEL,
|
|
"choices": [{
|
|
"index": 0,
|
|
"message": {"role": "assistant", "content": "external lemonade ok"},
|
|
"finish_reason": "stop"
|
|
}],
|
|
"usage": {"prompt_tokens": 1, "completion_tokens": 3, "total_tokens": 4}
|
|
})
|
|
else:
|
|
self._send(404, {"error": self.path})
|
|
|
|
ThreadingHTTPServer(("0.0.0.0", 13305), Handler).serve_forever()
|
|
PY
|
|
docker run -d --name "$mock_container" \
|
|
-p "$mock_publish" \
|
|
"${LEMONADE_E2E_MOCK_IMAGE:-python:3.12-bookworm}" python -u -c "$mock_code" >/dev/null
|
|
wait_url "${host_base}${api_path}/health" "mock Lemonade health is reachable" 120 \
|
|
|| fail "mock Lemonade did not become healthy"
|
|
else
|
|
host_base="$(strip_api_suffix "${LEMONADE_E2E_URL:-http://localhost:13305}")"
|
|
container_base="$(host_to_container_base "$host_base")"
|
|
note "Using real Lemonade service at $host_base"
|
|
wait_url "${host_base}${api_path}/health" "real Lemonade health is reachable" 30 \
|
|
|| fail "real Lemonade is not reachable at ${host_base}${api_path}/health"
|
|
if [[ -z "${LEMONADE_E2E_MODEL:-}" ]]; then
|
|
models_json="$(curl -fsS "${host_base}${api_path}/models")"
|
|
detected_model="$(json_get_first_model "$models_json")"
|
|
[[ -n "$detected_model" ]] || fail "could not detect model from ${host_base}${api_path}/models"
|
|
model="$detected_model"
|
|
pass "detected real Lemonade model: $model"
|
|
fi
|
|
fi
|
|
|
|
container_api_base="${container_base}${api_path}"
|
|
litellm_port="${LEMONADE_E2E_LITELLM_PORT:-$(free_port)}"
|
|
|
|
note "Checking container-side Lemonade reachability at $container_api_base"
|
|
ensure_ods_network
|
|
docker run --rm --network "$ods_network" --add-host=host.docker.internal:host-gateway curlimages/curl:8.11.1 \
|
|
-fsS "${container_api_base}/models" >/dev/null \
|
|
|| fail "Docker containers cannot reach Lemonade at ${container_api_base}/models"
|
|
pass "container-side Lemonade models endpoint is reachable"
|
|
|
|
cd "$runtime_dir"
|
|
|
|
note "Rendering external Lemonade LiteLLM config"
|
|
"$PY" scripts/render-runtime-configs.py \
|
|
--surface litellm-lemonade \
|
|
--ods-mode lemonade \
|
|
--gpu-backend amd \
|
|
--lemonade-model-id "$model" \
|
|
--lemonade-api-base "$container_api_base" \
|
|
--litellm-key "$lemonade_key" \
|
|
--output-root "$runtime_dir" \
|
|
--write >/dev/null
|
|
|
|
cat > .env <<ENV
|
|
BIND_ADDRESS=127.0.0.1
|
|
ODS_MODE=lemonade
|
|
GPU_BACKEND=amd
|
|
LLM_API_URL=http://litellm:4000
|
|
LLM_MODEL=${model}
|
|
LLM_BACKEND=lemonade
|
|
LLM_API_BASE_PATH=/api/v1
|
|
LEMONADE_EXTERNAL=true
|
|
LEMONADE_BASE_URL=${host_base}
|
|
LEMONADE_CONTAINER_BASE_URL=${container_base}
|
|
LEMONADE_API_BASE_PATH=/api/v1
|
|
LEMONADE_MODEL=${model}
|
|
AMD_INFERENCE_RUNTIME=lemonade
|
|
AMD_INFERENCE_BACKEND=auto
|
|
AMD_INFERENCE_LOCATION=host
|
|
AMD_INFERENCE_PORT=${host_base##*:}
|
|
AMD_INFERENCE_SUPPORTED_BACKENDS=auto
|
|
AMD_INFERENCE_RUNTIME_MODE=external-lemonade
|
|
AMD_INFERENCE_MANAGED=false
|
|
LITELLM_PORT=${litellm_port}
|
|
LITELLM_KEY=${litellm_key}
|
|
LITELLM_LEMONADE_API_KEY=${lemonade_key}
|
|
WEBUI_SECRET=e2e-webui-secret
|
|
N8N_PASS=e2e-n8n-pass
|
|
DASHBOARD_API_KEY=e2e-dashboard-key
|
|
ODS_AGENT_KEY=e2e-agent-key
|
|
ODS_SESSION_SECRET=e2e-session-secret
|
|
ENV
|
|
chmod 600 .env
|
|
|
|
cat > "$litellm_override_file" <<YAML
|
|
services:
|
|
litellm:
|
|
container_name: ${litellm_container}
|
|
YAML
|
|
|
|
"$PY" -c 'import yaml' >/dev/null 2>&1 || fail "PyYAML is required for compose resolver"
|
|
flags="$(LEMONADE_EXTERNAL=true ODS_MODE=lemonade AMD_INFERENCE_RUNTIME=lemonade AMD_INFERENCE_MANAGED=false \
|
|
ODS_PYTHON_CMD="$PY" ./scripts/resolve-compose-stack.sh \
|
|
--script-dir "$runtime_dir" \
|
|
--ods-mode lemonade \
|
|
--gpu-backend amd \
|
|
--tier SH_LARGE \
|
|
--env)"
|
|
[[ "$flags" == *"docker-compose.cloud.yml"* ]] || fail "external Lemonade stack must include cloud overlay"
|
|
[[ "$flags" == *"docker-compose.lemonade-external.yml"* ]] || fail "external Lemonade stack must include external overlay"
|
|
[[ "$flags" != *"docker-compose.amd.yml"* ]] || fail "external Lemonade stack must not include managed AMD overlay"
|
|
[[ "$flags" != *"compose.local.yaml"* ]] || fail "external Lemonade stack must not include local llama-server dependency overlays"
|
|
pass "compose resolver selects external Lemonade stack"
|
|
|
|
note "Starting LiteLLM through ODS compose on http://127.0.0.1:${litellm_port}"
|
|
docker compose \
|
|
-p "$compose_project" \
|
|
-f docker-compose.base.yml \
|
|
-f docker-compose.cloud.yml \
|
|
-f docker-compose.lemonade-external.yml \
|
|
-f extensions/services/litellm/compose.yaml \
|
|
-f "$litellm_override_file" \
|
|
up -d litellm >/dev/null
|
|
|
|
wait_url "http://127.0.0.1:${litellm_port}/health/readiness" "LiteLLM readiness is reachable" 180 \
|
|
|| {
|
|
docker logs "$litellm_container" --tail 200 >&2 || true
|
|
fail "LiteLLM did not become ready"
|
|
}
|
|
|
|
models_response="$(curl -fsS \
|
|
-H "Authorization: Bearer ${litellm_key}" \
|
|
"http://127.0.0.1:${litellm_port}/v1/models")"
|
|
[[ "$models_response" == *"default"* ]] || fail "LiteLLM model list did not expose default route"
|
|
pass "LiteLLM exposes external Lemonade route"
|
|
|
|
chat_response="$(curl -fsS \
|
|
-H "Authorization: Bearer ${litellm_key}" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"model":"default","messages":[{"role":"user","content":"ping"}],"max_tokens":16}' \
|
|
"http://127.0.0.1:${litellm_port}/v1/chat/completions")"
|
|
if [[ "$MODE" == "mock" ]]; then
|
|
[[ "$chat_response" == *"external lemonade ok"* ]] || {
|
|
echo "$chat_response" >&2
|
|
fail "LiteLLM chat completion did not traverse mock Lemonade"
|
|
}
|
|
else
|
|
[[ "$chat_response" == *'"object":"chat.completion"'* && "$chat_response" == *'"choices"'* ]] || {
|
|
echo "$chat_response" >&2
|
|
fail "LiteLLM did not return an OpenAI-compatible chat completion from real Lemonade"
|
|
}
|
|
fi
|
|
pass "LiteLLM chat completion traverses external Lemonade"
|
|
|
|
echo "[PASS] external Lemonade E2E (${MODE})"
|