Initial import with GitLab CI/CD and registry deploy flow
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DATA_ROOT = ROOT / ".data_gate3"
|
||||
|
||||
SERVICE_SPECS = [
|
||||
{"name": "auth", "module": "services.auth_service.app:app", "port": 28001},
|
||||
{"name": "audit", "module": "services.audit_service.app:app", "port": 28002},
|
||||
{"name": "customer", "module": "services.customer_service.app:app", "port": 28003},
|
||||
{"name": "interaction", "module": "services.interaction_service.app:app", "port": 28004},
|
||||
{"name": "routing", "module": "services.routing_service.app:app", "port": 28005},
|
||||
{"name": "voice", "module": "services.voice_adapter_service.app:app", "port": 28006},
|
||||
{"name": "telegram", "module": "services.telegram_adapter_service.app:app", "port": 28007},
|
||||
{"name": "kb", "module": "services.kb_service.app:app", "port": 28008},
|
||||
{"name": "reporting", "module": "services.reporting_service.app:app", "port": 28009},
|
||||
{"name": "supervisor", "module": "services.supervisor_service.app:app", "port": 28010},
|
||||
{"name": "gateway", "module": "gateway.app:app", "port": 28080},
|
||||
]
|
||||
|
||||
REQUIRED_STAGE3_SERVICES = ["kb", "reporting", "supervisor"]
|
||||
|
||||
|
||||
async def wait_for_health(base_url: str, retries: int = 80, delay: float = 0.25) -> None:
|
||||
async with httpx.AsyncClient(timeout=2) as client:
|
||||
for _ in range(retries):
|
||||
try:
|
||||
response = await client.get(f"{base_url}/health")
|
||||
if response.status_code == 200:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(delay)
|
||||
raise RuntimeError(f"Service not healthy: {base_url}/health")
|
||||
|
||||
|
||||
def start_one_service(spec: dict[str, Any], database_url: str | None, data_dir: Path) -> subprocess.Popen:
|
||||
env = os.environ.copy()
|
||||
env["CC_DATA_DIR"] = str(data_dir)
|
||||
if database_url:
|
||||
env["DATABASE_URL"] = database_url
|
||||
|
||||
if spec["name"] == "gateway":
|
||||
env["AUTH_SERVICE_URL"] = "http://127.0.0.1:28001"
|
||||
env["AUDIT_SERVICE_URL"] = "http://127.0.0.1:28002"
|
||||
env["CUSTOMER_SERVICE_URL"] = "http://127.0.0.1:28003"
|
||||
env["INTERACTION_SERVICE_URL"] = "http://127.0.0.1:28004"
|
||||
env["ROUTING_SERVICE_URL"] = "http://127.0.0.1:28005"
|
||||
env["VOICE_ADAPTER_SERVICE_URL"] = "http://127.0.0.1:28006"
|
||||
env["TELEGRAM_ADAPTER_SERVICE_URL"] = "http://127.0.0.1:28007"
|
||||
env["KB_SERVICE_URL"] = "http://127.0.0.1:28008"
|
||||
env["REPORTING_SERVICE_URL"] = "http://127.0.0.1:28009"
|
||||
env["SUPERVISOR_SERVICE_URL"] = "http://127.0.0.1:28010"
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"uvicorn",
|
||||
spec["module"],
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(spec["port"]),
|
||||
]
|
||||
return subprocess.Popen(
|
||||
cmd,
|
||||
cwd=str(ROOT),
|
||||
env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
async def start_services_sequential(database_url: str | None, data_dir: Path) -> list[subprocess.Popen]:
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
processes: list[subprocess.Popen] = []
|
||||
for spec in SERVICE_SPECS:
|
||||
proc = start_one_service(spec, database_url, data_dir)
|
||||
processes.append(proc)
|
||||
await wait_for_health(f"http://127.0.0.1:{spec['port']}", retries=60, delay=0.2)
|
||||
return processes
|
||||
|
||||
|
||||
def stop_services(processes: list[subprocess.Popen]) -> None:
|
||||
for proc in processes:
|
||||
if proc.poll() is not None:
|
||||
continue
|
||||
try:
|
||||
proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for proc in processes:
|
||||
try:
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def run_gate3_checks(base_url: str, reporting_url: str | None = None) -> bool:
|
||||
contract_path = ROOT / "contracts" / "openapi" / "stage3-kb-reporting-supervisor.yaml"
|
||||
if not contract_path.exists():
|
||||
print(f"[FAIL] Missing Stage 3 OpenAPI: {contract_path}")
|
||||
return False
|
||||
print(f"[PASS] Stage 3 OpenAPI present: {contract_path.name}")
|
||||
|
||||
operator = {"X-User": "operator", "X-Role": "operator"}
|
||||
analyst = {"X-User": "analyst", "X-Role": "analyst"}
|
||||
|
||||
async with httpx.AsyncClient(base_url=base_url, timeout=10) as client:
|
||||
health = await client.get("/health")
|
||||
if health.status_code != 200:
|
||||
print(f"[FAIL] gateway /health status={health.status_code}")
|
||||
return False
|
||||
|
||||
registry = await client.get("/registry")
|
||||
if registry.status_code != 200:
|
||||
print(f"[FAIL] gateway /registry status={registry.status_code}")
|
||||
return False
|
||||
services = registry.json().get("services", {})
|
||||
missing = [name for name in REQUIRED_STAGE3_SERVICES if name not in services]
|
||||
if missing:
|
||||
print(f"[FAIL] registry missing Stage 3 services: {missing}")
|
||||
return False
|
||||
print("[PASS] Gateway and registry checks")
|
||||
|
||||
denied = await client.post(
|
||||
"/proxy/kb/knowledge/categories",
|
||||
headers=operator,
|
||||
json={"name": "Forbidden", "description": "Role check"},
|
||||
)
|
||||
if denied.status_code != 403:
|
||||
print(f"[FAIL] KB role check expected 403, got {denied.status_code}")
|
||||
return False
|
||||
|
||||
category = await client.post(
|
||||
"/proxy/kb/knowledge/categories",
|
||||
headers=analyst,
|
||||
json={"name": "Stage 3 KB", "description": "Gate 3"},
|
||||
)
|
||||
if category.status_code != 200:
|
||||
print(f"[FAIL] KB category create status={category.status_code}")
|
||||
return False
|
||||
category_id = category.json()["category_id"]
|
||||
|
||||
article = await client.post(
|
||||
"/proxy/kb/knowledge/articles",
|
||||
headers=analyst,
|
||||
json={
|
||||
"category_id": category_id,
|
||||
"title": "Install helper",
|
||||
"body": "Step-by-step instruction for operator",
|
||||
"tags": ["stage3", "kb"],
|
||||
},
|
||||
)
|
||||
if article.status_code != 200:
|
||||
print(f"[FAIL] KB article create status={article.status_code}")
|
||||
return False
|
||||
|
||||
search = await client.get("/proxy/kb/knowledge/search?q=helper")
|
||||
if search.status_code != 200 or len(search.json()) < 1:
|
||||
print(
|
||||
f"[FAIL] KB search failed status={search.status_code} count={len(search.json()) if search.status_code == 200 else 0}"
|
||||
)
|
||||
return False
|
||||
print("[PASS] KB checks")
|
||||
|
||||
rows = [
|
||||
{
|
||||
"queue_id": "q_gate3",
|
||||
"answered": True,
|
||||
"wait_seconds": 15,
|
||||
"handle_seconds": 95,
|
||||
"abandoned": False,
|
||||
"resolved_first_contact": True,
|
||||
},
|
||||
{
|
||||
"queue_id": "q_gate3",
|
||||
"answered": False,
|
||||
"wait_seconds": 20,
|
||||
"handle_seconds": 0,
|
||||
"abandoned": True,
|
||||
"resolved_first_contact": False,
|
||||
},
|
||||
]
|
||||
for row in rows:
|
||||
ingested = await client.post("/proxy/reporting/reports/events", json=row)
|
||||
if ingested.status_code != 200:
|
||||
print(f"[FAIL] reporting ingest status={ingested.status_code}")
|
||||
return False
|
||||
|
||||
kpi = await client.get("/proxy/reporting/reports/kpi?queue_id=q_gate3&sl_threshold_seconds=30")
|
||||
if kpi.status_code != 200:
|
||||
print(f"[FAIL] reporting KPI status={kpi.status_code}")
|
||||
return False
|
||||
kpi_body = kpi.json()
|
||||
if "kpi" not in kpi_body or not {"SL", "ASA", "AHT", "Abandon", "FCR"}.issubset(set(kpi_body["kpi"])):
|
||||
print("[FAIL] KPI payload missing required keys")
|
||||
return False
|
||||
print("[PASS] Reporting KPI checks")
|
||||
|
||||
report_service_url = reporting_url or services.get("reporting")
|
||||
if not report_service_url:
|
||||
print("[FAIL] reporting service URL not found")
|
||||
return False
|
||||
|
||||
async with httpx.AsyncClient(base_url=report_service_url, timeout=10) as reporting_client:
|
||||
exported = await reporting_client.get("/reports/export")
|
||||
if exported.status_code != 200:
|
||||
print(f"[FAIL] reporting export status={exported.status_code}")
|
||||
return False
|
||||
if "queue_id,answered,wait_seconds,handle_seconds,abandoned,resolved_first_contact,created_at" not in exported.text:
|
||||
print("[FAIL] reporting export missing CSV header")
|
||||
return False
|
||||
if "q_gate3" not in exported.text:
|
||||
print("[FAIL] reporting export missing inserted data")
|
||||
return False
|
||||
print("[PASS] Reporting CSV export checks")
|
||||
|
||||
up1 = await client.post(
|
||||
"/proxy/supervisor/supervisor/agent-states",
|
||||
json={"agent_id": "a_gate3_1", "state": "READY", "queue_id": "q_gate3"},
|
||||
)
|
||||
up2 = await client.post(
|
||||
"/proxy/supervisor/supervisor/agent-states",
|
||||
json={"agent_id": "a_gate3_2", "state": "BUSY", "queue_id": "q_gate3"},
|
||||
)
|
||||
queue = await client.post(
|
||||
"/proxy/supervisor/supervisor/queue-metrics?queue_id=q_gate3&in_queue=3&avg_wait_seconds=21"
|
||||
)
|
||||
if up1.status_code != 200 or up2.status_code != 200 or queue.status_code != 200:
|
||||
print(
|
||||
f"[FAIL] supervisor updates status: agent1={up1.status_code}, agent2={up2.status_code}, queue={queue.status_code}"
|
||||
)
|
||||
return False
|
||||
|
||||
realtime = await client.get("/proxy/supervisor/supervisor/realtime")
|
||||
if realtime.status_code != 200:
|
||||
print(f"[FAIL] supervisor realtime status={realtime.status_code}")
|
||||
return False
|
||||
body: dict[str, Any] = realtime.json()
|
||||
if body.get("agents", {}).get("total", 0) < 2:
|
||||
print("[FAIL] supervisor realtime has less than 2 agents")
|
||||
return False
|
||||
queues = body.get("queues", [])
|
||||
if not any(item.get("queue_id") == "q_gate3" for item in queues):
|
||||
print("[FAIL] supervisor realtime missing q_gate3 queue snapshot")
|
||||
return False
|
||||
print("[PASS] Supervisor realtime checks")
|
||||
|
||||
print("[PASS] Gate 3 checks completed")
|
||||
return True
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Gate 3 checker")
|
||||
parser.add_argument("--base-url", default="http://localhost:8080", help="Gateway base URL")
|
||||
parser.add_argument("--auto-start", action="store_true", help="Auto-start local services for the check")
|
||||
parser.add_argument(
|
||||
"--database-url",
|
||||
default=os.getenv("DATABASE_URL", ""),
|
||||
help="Optional DB URL for auto-start mode",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.auto_start:
|
||||
ok = await run_gate3_checks(args.base_url)
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
db_url = args.database_url.strip() or None
|
||||
run_data_dir = DATA_ROOT / f"run_{int(time.time() * 1000)}"
|
||||
processes: list[subprocess.Popen] = []
|
||||
try:
|
||||
processes = await start_services_sequential(db_url, run_data_dir)
|
||||
ok = await run_gate3_checks("http://127.0.0.1:28080", reporting_url="http://127.0.0.1:28009")
|
||||
if db_url:
|
||||
print(f"[INFO] DB mode: {db_url}")
|
||||
sys.exit(0 if ok else 1)
|
||||
finally:
|
||||
stop_services(processes)
|
||||
try:
|
||||
shutil.rmtree(run_data_dir)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user