* feat: introduce hindsight-api-slim and hindsight-all-slim packages Closes #552 - Move all source code from hindsight-api/ to new hindsight-api-slim/ - hindsight-api-slim has heavy ML deps (torch, sentence-transformers, transformers, einops, flashrank, mlx, mlx-lm, safetensors) and pg0-embedded as optional extras: [local-ml], [embedded-db], [all] - hindsight-api becomes a zero-code meta-package depending on hindsight-api-slim[all] for full backward compatibility - Add hindsight-all-slim meta-package: hindsight-api-slim + client + embed - hindsight-all updated to depend on hindsight-api-slim[all] - pg0.py: lazy-import pg0 with clear ImportError pointing to [embedded-db] - Dockerfile: replace sed hack with proper uv sync --extra flags - Update release.yml, test.yml, lint.sh, release.sh, CLAUDE.md and all path references throughout the repo * refactor: rename hindsight/ directory to hindsight-all/ * docs: document hindsight-api-slim and hindsight-all-slim package variants Add package variants table and extras explanation to installation.md * docs: remove emojis from installation.md, use professional tone * docs: link Docker slim variant to pip package variants section * docs: consolidate Docker image variants into single table * ci: fix working-directory paths after package restructure - Replace all hindsight-api → hindsight-api-slim in test.yml - Replace hindsight → hindsight-all in test.yml - Add --extra embedded-db to test-embed API install step * ci: add local-ml and embedded-db extras to API sync steps These extras were previously implicit in the old hindsight-api package (which bundled everything). Now that hindsight-api-slim uses optional extras, we must explicitly request local-ml and embedded-db in CI. * ci: add API install step with embedded-db to test-embed smoke test The smoke test starts hindsight-api as a daemon, which requires pg0-embedded. Add a dedicated install step for hindsight-api-slim with embedded-db extra so the daemon can start successfully. * ci: remove --no-install-project when using optional extras When --no-install-project is combined with --extra, the optional deps are not installed because extras require the project to be active. Remove --no-install-project from steps that need local-ml or embedded-db. * ci: fix ordering of uv sync steps to preserve optional extras When uv sync runs for a different workspace member, it removes optional extras installed for other members. Fix by always running extra-requiring API sync last, after other workspace member syncs. Also remove --no-install-project from embedded-db sync in test-embed, as --no-install-project prevents optional extras from being active. * ci: add local-ml extra to test-embed API install for smoke test The smoke test starts the full API server which needs sentence-transformers for local embeddings (default provider). Add local-ml extra to the install. * ci: simplify extras with --all-extras and add slim pip smoke test - Replace explicit --extra local-ml --extra embedded-db with --all-extras for cleaner, more maintainable sync steps - Add test-pip-slim job: tests hindsight-api-slim[embedded-db] without local ML models, using Cohere for embeddings/reranking (mirrors Docker slim smoke test approach) * ci: simplify slim smoke test to health check only (mirrors Docker test)
494 lines
16 KiB
Python
494 lines
16 KiB
Python
"""
|
|
Integration test for base path deployment using Docker Compose.
|
|
|
|
This test validates that Hindsight works correctly when deployed
|
|
behind a reverse proxy with path-based routing using the actual
|
|
Docker Compose examples from docker/compose-examples/.
|
|
|
|
Tests:
|
|
1. API with base path (direct, no proxy)
|
|
2. Full stack via docker-compose with Nginx reverse proxy
|
|
3. Regression: API without base path still works
|
|
|
|
Requirements:
|
|
- Docker and docker-compose installed
|
|
- No nginx required on host!
|
|
"""
|
|
import asyncio
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
# Paths
|
|
REPO_ROOT = Path(__file__).parent.parent.parent
|
|
API_PATH = REPO_ROOT / "hindsight-api-slim"
|
|
COMPOSE_EXAMPLES_PATH = REPO_ROOT / "docker" / "docker-compose" / "nginx"
|
|
|
|
# Add hindsight-api to path for direct API testing
|
|
sys.path.insert(0, str(API_PATH))
|
|
|
|
|
|
def run_command(cmd: list[str], cwd: str | Path | None = None, env: dict | None = None) -> subprocess.CompletedProcess:
|
|
"""Run a command and return the result."""
|
|
return subprocess.run(
|
|
cmd,
|
|
cwd=cwd,
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
|
|
def check_docker_available() -> bool:
|
|
"""Check if Docker is available."""
|
|
result = run_command(["docker", "info"])
|
|
return result.returncode == 0
|
|
|
|
|
|
def get_docker_compose_command() -> list[str]:
|
|
"""Get the docker-compose command (modern or legacy)."""
|
|
import shutil
|
|
# Try modern docker compose plugin first
|
|
if shutil.which("docker"):
|
|
result = run_command(["docker", "compose", "version"])
|
|
if result.returncode == 0:
|
|
return ["docker", "compose"]
|
|
# Fall back to legacy docker-compose
|
|
if shutil.which("docker-compose"):
|
|
return ["docker-compose"]
|
|
raise RuntimeError("docker-compose not available")
|
|
|
|
|
|
def check_docker_compose_available() -> bool:
|
|
"""Check if docker-compose is available."""
|
|
try:
|
|
get_docker_compose_command()
|
|
return True
|
|
except RuntimeError:
|
|
return False
|
|
|
|
|
|
class APIServer:
|
|
"""Helper to manage API server lifecycle for direct testing."""
|
|
|
|
def __init__(self, base_path: str | None = None, port: int = 18888):
|
|
self.base_path = base_path
|
|
self.port = port
|
|
self.process = None
|
|
self.env = os.environ.copy()
|
|
if base_path:
|
|
self.env["HINDSIGHT_API_BASE_PATH"] = base_path
|
|
|
|
def start(self):
|
|
"""Start the API server."""
|
|
cmd = [
|
|
"uv",
|
|
"run",
|
|
"--directory",
|
|
str(API_PATH),
|
|
"hindsight-api",
|
|
"--host",
|
|
"0.0.0.0",
|
|
"--port",
|
|
str(self.port),
|
|
]
|
|
|
|
log_file = f"/tmp/hindsight-api-{self.port}.log"
|
|
self.log_file = open(log_file, "w")
|
|
|
|
self.process = subprocess.Popen(
|
|
cmd, env=self.env, stdout=self.log_file, stderr=subprocess.STDOUT
|
|
)
|
|
|
|
# Wait for server to be ready
|
|
base_url = f"http://localhost:{self.port}"
|
|
if self.base_path:
|
|
health_url = f"{base_url}{self.base_path}/health"
|
|
else:
|
|
health_url = f"{base_url}/health"
|
|
|
|
for _ in range(60): # 60 second timeout
|
|
try:
|
|
response = httpx.get(health_url, timeout=2.0)
|
|
if response.status_code == 200:
|
|
return
|
|
except (httpx.ConnectError, httpx.ReadTimeout):
|
|
pass
|
|
time.sleep(1)
|
|
|
|
# Failed to start
|
|
self.log_file.flush()
|
|
with open(log_file) as f:
|
|
print(f"API server failed to start. Logs:\n{f.read()}")
|
|
raise RuntimeError(f"API server failed to start on port {self.port}")
|
|
|
|
def stop(self):
|
|
"""Stop the API server."""
|
|
if self.process:
|
|
self.process.terminate()
|
|
try:
|
|
self.process.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
self.process.kill()
|
|
self.process.wait()
|
|
self.process = None
|
|
|
|
if hasattr(self, "log_file") and self.log_file:
|
|
self.log_file.close()
|
|
|
|
def __enter__(self):
|
|
self.start()
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
self.stop()
|
|
|
|
|
|
class DockerComposeStack:
|
|
"""Helper to manage docker-compose stack lifecycle."""
|
|
|
|
def __init__(self, compose_file: Path, project_name: str = "hindsight-test"):
|
|
self.compose_file = compose_file
|
|
self.project_name = project_name
|
|
self.compose_cmd = get_docker_compose_command()
|
|
self.env = os.environ.copy()
|
|
# Set required env vars for docker-compose
|
|
self.env["OPENAI_API_KEY"] = os.environ.get("OPENAI_API_KEY", "test-key")
|
|
self.env["HINDSIGHT_API_LLM_PROVIDER"] = os.environ.get("HINDSIGHT_API_LLM_PROVIDER", "mock")
|
|
self.env["HINDSIGHT_API_LLM_MODEL"] = os.environ.get("HINDSIGHT_API_LLM_MODEL", "mock-model")
|
|
|
|
def start(self, timeout: int = 120):
|
|
"""Start the docker-compose stack."""
|
|
print(f"Starting docker-compose stack: {self.compose_file.name}")
|
|
|
|
# Pull images first (but don't fail if it doesn't work)
|
|
run_command(
|
|
self.compose_cmd + ["-f", str(self.compose_file), "-p", self.project_name, "pull"],
|
|
cwd=self.compose_file.parent,
|
|
env=self.env,
|
|
)
|
|
|
|
# Start services
|
|
result = run_command(
|
|
self.compose_cmd + ["-f", str(self.compose_file), "-p", self.project_name, "up", "-d", "--build"],
|
|
cwd=self.compose_file.parent,
|
|
env=self.env,
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
print(f"Failed to start docker-compose:\nSTDOUT: {result.stdout}\nSTDERR: {result.stderr}")
|
|
raise RuntimeError("Failed to start docker-compose stack")
|
|
|
|
# Wait for services to be healthy
|
|
start_time = time.time()
|
|
while time.time() - start_time < timeout:
|
|
result = run_command(
|
|
self.compose_cmd + ["-f", str(self.compose_file), "-p", self.project_name, "ps", "--format", "json"],
|
|
cwd=self.compose_file.parent,
|
|
env=self.env,
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
# Give it a few more seconds to fully initialize
|
|
time.sleep(5)
|
|
return
|
|
|
|
time.sleep(2)
|
|
|
|
# Timeout - show logs and fail
|
|
self.show_logs()
|
|
raise RuntimeError(f"Docker compose stack failed to start within {timeout}s")
|
|
|
|
def show_logs(self):
|
|
"""Show docker-compose logs."""
|
|
result = run_command(
|
|
self.compose_cmd + ["-f", str(self.compose_file), "-p", self.project_name, "logs", "--tail=100"],
|
|
cwd=self.compose_file.parent,
|
|
env=self.env,
|
|
)
|
|
print(f"Docker compose logs:\n{result.stdout}\n{result.stderr}")
|
|
|
|
def stop(self):
|
|
"""Stop and remove the docker-compose stack."""
|
|
print(f"Stopping docker-compose stack: {self.compose_file.name}")
|
|
result = run_command(
|
|
self.compose_cmd + ["-f", str(self.compose_file), "-p", self.project_name, "down", "-v"],
|
|
cwd=self.compose_file.parent,
|
|
env=self.env,
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
print(f"Warning: Failed to stop docker-compose:\n{result.stderr}")
|
|
|
|
def __enter__(self):
|
|
self.start()
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
self.stop()
|
|
|
|
|
|
def test_api_without_base_path():
|
|
"""Regression test: API works at root path (default behavior)."""
|
|
with APIServer(base_path=None, port=18888) as server:
|
|
base_url = f"http://localhost:{server.port}"
|
|
|
|
# Health check
|
|
response = httpx.get(f"{base_url}/health")
|
|
assert response.status_code == 200
|
|
assert "status" in response.json()
|
|
|
|
# API endpoints
|
|
response = httpx.get(f"{base_url}/v1/default/banks")
|
|
assert response.status_code == 200
|
|
assert "banks" in response.json()
|
|
|
|
# OpenAPI docs
|
|
response = httpx.get(f"{base_url}/docs")
|
|
assert response.status_code == 200
|
|
|
|
|
|
def test_api_with_base_path_direct():
|
|
"""Test API with base path configuration (direct, no proxy)."""
|
|
base_path = "/hindsight"
|
|
|
|
with APIServer(base_path=base_path, port=18889) as server:
|
|
base_url = f"http://localhost:{server.port}"
|
|
|
|
# Base path SHOULD work
|
|
response = httpx.get(f"{base_url}{base_path}/health")
|
|
assert response.status_code == 200
|
|
assert "status" in response.json()
|
|
|
|
# API endpoints with base path
|
|
response = httpx.get(f"{base_url}{base_path}/v1/default/banks")
|
|
assert response.status_code == 200
|
|
assert "banks" in response.json()
|
|
|
|
# OpenAPI docs with base path
|
|
response = httpx.get(f"{base_url}{base_path}/docs")
|
|
assert response.status_code == 200
|
|
|
|
# OpenAPI schema should have correct server URL
|
|
response = httpx.get(f"{base_url}{base_path}/openapi.json")
|
|
assert response.status_code == 200
|
|
openapi = response.json()
|
|
assert "servers" in openapi
|
|
assert openapi["servers"][0]["url"] == base_path
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
not check_docker_compose_available(),
|
|
reason="docker-compose not available"
|
|
)
|
|
def test_reverse_proxy_simple_config():
|
|
"""
|
|
Test reverse proxy deployment using docker-compose with Nginx.
|
|
|
|
This creates a minimal test setup with:
|
|
- API server running on HOST via uv (not Docker - faster, no image build needed!)
|
|
- Nginx container that proxies to the host API
|
|
|
|
This tests the actual reverse proxy scenario without requiring the
|
|
heavy Hindsight Docker image.
|
|
"""
|
|
base_path = "/hindsight"
|
|
api_port = 18890
|
|
|
|
# Start API on host with base path
|
|
with APIServer(base_path=base_path, port=api_port):
|
|
# Create test docker-compose file (nginx only)
|
|
test_compose = COMPOSE_EXAMPLES_PATH / "test-reverse-proxy.yml"
|
|
|
|
# Determine host address for nginx to reach host machine
|
|
# host.docker.internal works on Docker Desktop (Mac/Windows)
|
|
# On Linux, we use host network mode
|
|
import platform
|
|
if platform.system() == "Linux":
|
|
network_mode = "host"
|
|
api_host = "localhost"
|
|
nginx_port = 18080 # With host mode, nginx must listen on 18080 directly
|
|
port_mapping = "" # No port mapping with host mode
|
|
else:
|
|
network_mode = "bridge"
|
|
api_host = "host.docker.internal"
|
|
nginx_port = 80 # With bridge mode, nginx listens on 80 and is mapped
|
|
port_mapping = """ ports:
|
|
- "18080:80"
|
|
"""
|
|
|
|
compose_content = f"""version: '3.8'
|
|
|
|
services:
|
|
nginx:
|
|
image: nginx:alpine
|
|
{port_mapping} volumes:
|
|
- ./test-nginx.conf:/etc/nginx/nginx.conf:ro
|
|
network_mode: {network_mode}
|
|
"""
|
|
|
|
# Create test nginx config
|
|
nginx_config = f"""events {{
|
|
worker_connections 1024;
|
|
}}
|
|
|
|
http {{
|
|
server {{
|
|
listen {nginx_port};
|
|
server_name localhost;
|
|
|
|
location {base_path}/ {{
|
|
proxy_pass http://{api_host}:{api_port};
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
}}
|
|
}}
|
|
}}
|
|
"""
|
|
|
|
# Write test files
|
|
test_compose.write_text(compose_content)
|
|
test_nginx_conf = COMPOSE_EXAMPLES_PATH / "test-nginx.conf"
|
|
test_nginx_conf.write_text(nginx_config)
|
|
|
|
try:
|
|
# Start nginx via docker-compose
|
|
with DockerComposeStack(test_compose, project_name="hindsight-base-path-test"):
|
|
proxy_url = "http://localhost:18080"
|
|
|
|
# Give nginx a moment to start
|
|
time.sleep(2)
|
|
|
|
# Test through nginx proxy
|
|
response = httpx.get(f"{proxy_url}{base_path}/health", timeout=10.0)
|
|
assert response.status_code == 200
|
|
assert "status" in response.json()
|
|
|
|
# API endpoints through proxy
|
|
response = httpx.get(f"{proxy_url}{base_path}/v1/default/banks", timeout=10.0)
|
|
assert response.status_code == 200
|
|
assert "banks" in response.json()
|
|
|
|
# OpenAPI docs through proxy
|
|
response = httpx.get(f"{proxy_url}{base_path}/docs", timeout=10.0)
|
|
assert response.status_code == 200
|
|
|
|
finally:
|
|
# Cleanup test files
|
|
test_compose.unlink(missing_ok=True)
|
|
test_nginx_conf.unlink(missing_ok=True)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_full_workflow_with_base_path():
|
|
"""Test full retain/recall workflow through base path."""
|
|
base_path = "/hindsight"
|
|
bank_id = "integration_test_bank"
|
|
|
|
with APIServer(base_path=base_path, port=18891) as server:
|
|
base_url = f"http://localhost:{server.port}{base_path}"
|
|
|
|
async with httpx.AsyncClient(base_url=base_url, timeout=30.0) as client:
|
|
# 1. Get bank profile (creates if needed)
|
|
response = await client.get(f"/v1/default/banks/{bank_id}/profile")
|
|
assert response.status_code == 200
|
|
|
|
# 2. Store a memory
|
|
response = await client.post(
|
|
f"/v1/default/banks/{bank_id}/memories",
|
|
json={
|
|
"items": [
|
|
{
|
|
"content": "Hindsight supports deployment under custom base paths for reverse proxy scenarios.",
|
|
"context": "integration test"
|
|
}
|
|
]
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
result = response.json()
|
|
assert result["success"] is True
|
|
|
|
# 3. Recall the memory
|
|
response = await client.post(
|
|
f"/v1/default/banks/{bank_id}/memories/recall",
|
|
json={"query": "base path deployment"}
|
|
)
|
|
assert response.status_code == 200
|
|
recall_result = response.json()
|
|
assert "results" in recall_result
|
|
# Should find our memory
|
|
assert len(recall_result["results"]) > 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
"""Run tests directly with python."""
|
|
import sys
|
|
|
|
print("=" * 70)
|
|
print("Hindsight Base Path Integration Tests")
|
|
print("=" * 70)
|
|
print()
|
|
|
|
all_passed = True
|
|
|
|
# Test 1: Without base path
|
|
print("Test 1: API without base path (regression test)")
|
|
print("-" * 70)
|
|
try:
|
|
test_api_without_base_path()
|
|
print("✅ PASSED\n")
|
|
except Exception as e:
|
|
print(f"❌ FAILED: {e}\n")
|
|
all_passed = False
|
|
|
|
# Test 2: With base path (direct)
|
|
print("Test 2: API with base path (direct, no proxy)")
|
|
print("-" * 70)
|
|
try:
|
|
test_api_with_base_path_direct()
|
|
print("✅ PASSED\n")
|
|
except Exception as e:
|
|
print(f"❌ FAILED: {e}\n")
|
|
all_passed = False
|
|
|
|
# Test 3: Docker compose reverse proxy
|
|
print("Test 3: Reverse proxy via docker-compose")
|
|
print("-" * 70)
|
|
if check_docker_available() and check_docker_compose_available():
|
|
try:
|
|
test_reverse_proxy_simple_config()
|
|
print("✅ PASSED\n")
|
|
except Exception as e:
|
|
print(f"❌ FAILED: {e}\n")
|
|
import traceback
|
|
traceback.print_exc()
|
|
all_passed = False
|
|
else:
|
|
print("⚠️ SKIPPED: Docker or docker-compose not available\n")
|
|
|
|
# Test 4: Full workflow
|
|
print("Test 4: Full retain/recall workflow with base path")
|
|
print("-" * 70)
|
|
try:
|
|
asyncio.run(test_full_workflow_with_base_path())
|
|
print("✅ PASSED\n")
|
|
except Exception as e:
|
|
print(f"❌ FAILED: {e}\n")
|
|
all_passed = False
|
|
|
|
# Summary
|
|
print("=" * 70)
|
|
if all_passed:
|
|
print("✅ All tests passed!")
|
|
sys.exit(0)
|
|
else:
|
|
print("❌ Some tests failed")
|
|
sys.exit(1)
|