Spaces:
Sleeping
Sleeping
File size: 8,866 Bytes
e382248 1c1cc75 e382248 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 | #!/usr/bin/env python
"""Check every external dependency this app needs before a deploy.
Each service is probed independently so a failure names the service rather than
surfacing as a generic 500 three layers up. Exits non-zero if anything required
is down, so it can gate a deploy.
.venv/bin/python scripts/preflight.py
"""
from __future__ import annotations
import os
import sys
import time
from pathlib import Path
from dotenv import load_dotenv
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
load_dotenv(PROJECT_ROOT / ".env")
# Quiet the app's structured logging so results stay readable.
import logging
logging.disable(logging.CRITICAL)
PASS, FAIL, WARN = "PASS", "FAIL", "WARN"
results: list[tuple[str, str, str, float]] = []
def check(name: str, required: bool = True):
"""Run a probe, recording pass/fail and how long it took."""
def decorator(fn):
started = time.monotonic()
try:
detail = fn() or "ok"
status = PASS
except Exception as e:
detail = f"{type(e).__name__}: {e}".replace("\n", " ")[:160]
status = FAIL if required else WARN
results.append((name, status, detail, time.monotonic() - started))
return fn
return decorator
@check("Config / env")
def _config():
from app.core.config import settings
missing = [
k
for k in ("nvidia_api_key", "database_url", "secret_key")
if not getattr(settings, k, None)
]
if missing:
raise RuntimeError(f"missing settings: {', '.join(missing)}")
return f"environment={settings.environment}"
@check("Database (Postgres)")
def _database():
from sqlalchemy import create_engine, inspect, text
engine = create_engine(os.environ["DATABASE_URL"], pool_pre_ping=True)
with engine.connect() as c:
version = c.execute(text("select version()")).scalar() or ""
head = c.execute(text("select version_num from alembic_version")).scalar()
tables = inspect(engine).get_table_names()
if "architecture_decisions" not in tables:
raise RuntimeError("architecture_decisions missing - run alembic upgrade head")
return f"{version.split(',')[0][:34]} | {len(tables)} tables | head={head}"
@check("NVIDIA chat (authoring model)")
def _nvidia_chat():
from app.core.llm_factory import get_chat_model
from app.core.schemas import TeamRole
llm = get_chat_model(role=TeamRole.SOLUTION_ARCHITECT, max_tokens=16)
reply = llm.invoke([{"role": "user", "content": "Reply with the single word: ready"}])
text = getattr(reply, "content", str(reply)).strip()
if not text:
raise RuntimeError("empty completion")
return f"responded {text[:40]!r}"
@check("NVIDIA embeddings")
def _nvidia_embeddings():
from app.core.llm_factory import get_embeddings_model
vec = get_embeddings_model().embed_query("preflight")
if not vec:
raise RuntimeError("empty embedding")
return f"dim={len(vec)}"
@check("Upstash Redis", required=False)
def _redis():
url, token = os.getenv("UPSTASH_REDIS_REST_URL"), os.getenv("UPSTASH_REDIS_REST_TOKEN")
if not (url and token):
raise RuntimeError("not configured")
import httpx
r = httpx.get(f"{url}/set/preflight/ok", headers={"Authorization": f"Bearer {token}"}, timeout=10)
r.raise_for_status()
g = httpx.get(f"{url}/get/preflight", headers={"Authorization": f"Bearer {token}"}, timeout=10)
g.raise_for_status()
return f"set/get round-trip ok ({g.json().get('result')})"
@check("Pinecone", required=False)
def _pinecone():
key, index = os.getenv("PINECONE_API_KEY"), os.getenv("PINECONE_INDEX")
if not key:
raise RuntimeError("not configured")
from pinecone import Pinecone
names = [i["name"] for i in Pinecone(api_key=key).list_indexes()]
if index not in names:
raise RuntimeError(f"index {index!r} does not exist (available: {names or 'none'})")
return f"index {index!r} present"
@check("LangSmith tracing", required=False)
def _langsmith():
"""Verify the tracer the app actually builds can reach the ingest endpoint.
Two false greens preceded this:
1. `/info` is unauthenticated and returns 200 for any key, so it could not
see a 401 at all.
2. Probing `os.getenv("LANGSMITH_API_KEY")` against `/api/v1/sessions`
tested *this script's* environment and the *read* path. Both were wrong.
`load_dotenv()` at the top of this file puts the key in preflight's
environment; the app never calls it, so the tracer's default client saw
no key and every trace was rejected 401 by `/runs/multipart` while this
check printed PASS. Reading also proved nothing about writing - the same
key returned 200 from `/api/v1/sessions` and 401 from `/runs/multipart`.
So: build the callbacks through the app's own factory, and probe the ingest
route with the credentials that tracer ended up holding.
"""
import httpx
from app.core.llm_factory import get_langsmith_callbacks
# Build the callbacks with the LangSmith/LangChain variables removed from
# os.environ, because that is the app's real condition: `scripts/dev.sh`
# execs uvicorn with only DATABASE_URL set, and the Docker image passes no
# LANGSMITH_* either. `load_dotenv()` at the top of this file would
# otherwise hand the tracer a key through a channel the app does not have,
# which is precisely how the previous version of this check passed while
# every trace was dropped. `settings` was populated at import time and is
# unaffected, so a correctly-wired tracer still finds its key.
stashed = {
k: os.environ.pop(k)
for k in list(os.environ)
if k.startswith(("LANGSMITH_", "LANGCHAIN_"))
}
try:
callbacks = get_langsmith_callbacks()
finally:
os.environ.update(stashed)
if not callbacks:
raise RuntimeError(
"tracing disabled, or LANGSMITH_API_KEY missing from settings"
)
client = getattr(callbacks[0], "client", None)
if client is None or not getattr(client, "api_key", None):
raise RuntimeError(
"tracer has no credentialed client - traces will be dropped 401"
)
# An empty body is rejected 422 by a *credentialed* request and 401/403 by an
# uncredentialed one, so this distinguishes them without writing a run.
r = httpx.post(
f"{client.api_url}/runs/multipart",
headers={"x-api-key": client.api_key},
timeout=10,
)
if r.status_code in (401, 403):
raise RuntimeError(
f"{r.status_code} on /runs/multipart - traces are being dropped"
)
return f"ingest authenticated ({client.api_url})"
@check("Google OAuth config", required=False)
def _google():
"""Check the redirect URI is right *for this environment*.
A localhost redirect is correct when developing locally and wrong only when
shipping. Flagging it unconditionally meant every local run reported a
degraded check that needed no action, which is how a warning stops being
read. Only ENVIRONMENT=production makes localhost a real problem.
"""
cid, secret = os.getenv("GOOGLE_CLIENT_ID"), os.getenv("GOOGLE_CLIENT_SECRET")
if not (cid and secret):
raise RuntimeError("not configured")
redirect = os.getenv("GOOGLE_REDIRECT_URI", "")
if not redirect:
raise RuntimeError("GOOGLE_REDIRECT_URI is not set")
is_local = "localhost" in redirect or "127.0.0.1" in redirect
in_production = os.getenv("ENVIRONMENT", "development").lower() == "production"
if is_local and in_production:
raise RuntimeError(f"ENVIRONMENT=production but redirect URI is local: {redirect}")
if is_local:
return f"{redirect} (local - set a public URI before deploying)"
if not redirect.startswith("https://"):
raise RuntimeError(f"non-local redirect URI must use https: {redirect}")
return redirect
def main() -> int:
width = max(len(n) for n, *_ in results)
print()
for name, status, detail, secs in results:
colour = {PASS: "\033[32m", FAIL: "\033[31m", WARN: "\033[33m"}[status]
print(f" {colour}{status:4}\033[0m {name:<{width}} {secs:5.2f}s {detail}")
failed = [n for n, s, *_ in results if s == FAIL]
warned = [n for n, s, *_ in results if s == WARN]
print()
if failed:
print(f" \033[31m{len(failed)} required check(s) failed:\033[0m {', '.join(failed)}")
if warned:
print(f" \033[33m{len(warned)} optional check(s) degraded:\033[0m {', '.join(warned)}")
if not failed and not warned:
print(" \033[32mAll checks passed.\033[0m")
print()
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())
|