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())