From b3c0f8deaf4a4a2492491fd9ad1c13afbd12f0a0 Mon Sep 17 00:00:00 2001 From: bharat <109189442+gothicscientist@users.noreply.github.com> Date: Sun, 1 Nov 2026 02:52:35 -0800 Subject: [PATCH 1/5] Prepare zero-idle deployment and request-scoped AI keys --- .dockerignore | 17 ++++ .gitattributes | 1 + .github/workflows/runtime-image.yml | 33 +++++++ .gitignore | 3 + apps/web/pnpm-workspace.yaml | 2 + apps/web/src/app/assistant/page.tsx | 62 ++++++++++--- apps/web/src/components/SiteFooter.tsx | 2 +- apps/web/src/lib/api.ts | 9 +- apps/web/src/lib/types.ts | 1 + services/api/strata/agent/providers.py | 50 +++++++++-- services/api/strata/api/routers/chat.py | 15 +++- services/api/strata/api/routers/health.py | 2 +- services/api/strata/api/schemas.py | 16 +++- services/api/strata/db/session.py | 2 +- services/api/strata/jobs/worker.py | 8 +- services/api/tests/api/test_byok.py | 88 +++++++++++++++++++ services/worker/Dockerfile | 19 +++- .../20261101104046_deployment_hardening.sql | 10 +++ 18 files changed, 310 insertions(+), 30 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 .github/workflows/runtime-image.yml create mode 100644 apps/web/pnpm-workspace.yaml create mode 100644 services/api/tests/api/test_byok.py create mode 100644 supabase/migrations/20261101104046_deployment_hardening.sql diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4f61124 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.venv +**/node_modules +**/.next +**/out +**/__pycache__ +**/.pytest_cache +**/.ruff_cache +.env* +**/.env* +.deployment +.deployment-build.log +supabase/.temp +data/raw +data/work +data/derived/f2 +docs/screenshots diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/runtime-image.yml b/.github/workflows/runtime-image.yml new file mode 100644 index 0000000..ba05bfb --- /dev/null +++ b/.github/workflows/runtime-image.yml @@ -0,0 +1,33 @@ +name: Build deployment runtime +on: + workflow_dispatch: + push: + branches: [codex/deploy-stratalab-cc] + paths: [services/**, data/**, docs/methods/**, .dockerignore, .github/workflows/runtime-image.yml] +permissions: + contents: read + packages: write +jobs: + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@v6 + with: + context: . + file: services/worker/Dockerfile + push: true + tags: | + ghcr.io/gothicscientist/strata-runtime:${{ github.sha }} + ghcr.io/gothicscientist/strata-runtime:portfolio + labels: | + org.opencontainers.image.source=https://github.com/gothicscientist/strata + org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index b13cd52..49fc2a0 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ node_modules/ out/ .env .env.local +.deployment/ +.deployment-build.log +supabase/.temp/ data/raw/ data/work/ *.egg-info/ diff --git a/apps/web/pnpm-workspace.yaml b/apps/web/pnpm-workspace.yaml new file mode 100644 index 0000000..a83ee7e --- /dev/null +++ b/apps/web/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + es5-ext: false diff --git a/apps/web/src/app/assistant/page.tsx b/apps/web/src/app/assistant/page.tsx index b68c0c1..bbdb164 100644 --- a/apps/web/src/app/assistant/page.tsx +++ b/apps/web/src/app/assistant/page.tsx @@ -220,10 +220,14 @@ export default function AssistantPage() { const [shared, setShared] = useState([]); const [streaming, setStreaming] = useState(false); const [error, setError] = useState(null); + const [provider, setProvider] = useState<"anthropic" | "openai">("openai"); + const [apiKey, setApiKey] = useState(""); + const [model, setModel] = useState(""); const abortRef = useRef(null); const endRef = useRef(null); - const configured = health ? health.capabilities.llm_configured : null; + const configured = Boolean(health?.capabilities.llm_configured || apiKey.trim().length >= 8); + const connected = session.status === "guest" || session.status === "signed-in"; // Restore the conversation from this tab's previous visit. useEffect(() => { @@ -246,7 +250,7 @@ export default function AssistantPage() { async function send() { const content = input.trim(); - if (!content || streaming) return; + if (!content || streaming || !configured || !connected) return; setError(null); setStreaming(true); const ctrl = new AbortController(); @@ -261,7 +265,10 @@ export default function AssistantPage() { } setInput(""); setTurns((t) => [...t, { role: "user", content }, emptyAssistant()]); - for await (const ev of streamChat(sid, content, ctrl.signal)) { + const credentials = apiKey.trim() + ? { provider, api_key: apiKey.trim(), ...(model.trim() ? { model: model.trim() } : {}) } + : undefined; + for await (const ev of streamChat(sid, content, ctrl.signal, credentials)) { setTurns((t) => { const last = t[t.length - 1]; if (!last || last.role !== "assistant") return t; @@ -307,15 +314,43 @@ export default function AssistantPage() { } /> - {configured === false ? ( - - No language-model provider has an API key, so questions cannot be answered. The operator - can set one as described in docs/DEPLOYMENT.md. All analyses remain available - from the composer. - - ) : null} -
+
+ Use your own API key +

+ Your provider bills your API account. The key stays in this page's memory and is + sent securely to Strata for each request, then to your selected provider. It is + never saved. Reloading the page clears it. All analyses also work without AI in + the composer. +

+
+ + +
+ + {apiKey ? : null} +
{turns.length === 0 ? (

For example

@@ -394,7 +429,7 @@ export default function AssistantPage() { className="field h-28 resize-y !font-serif !text-body !leading-[1.5]" value={input} maxLength={8000} - disabled={configured === false} + disabled={!connected || streaming} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { @@ -410,7 +445,7 @@ export default function AssistantPage() { Stop ) : ( - )} @@ -420,6 +455,7 @@ export default function AssistantPage() { ) : null}
+ {!configured ?

Enter an API key above to ask the assistant.

: null}
diff --git a/apps/web/src/components/SiteFooter.tsx b/apps/web/src/components/SiteFooter.tsx index a7dfa60..9dfa27f 100644 --- a/apps/web/src/components/SiteFooter.tsx +++ b/apps/web/src/components/SiteFooter.tsx @@ -27,7 +27,7 @@ export function SiteFooter() {
{caps ? (caps.qpadm ? "available" : "unavailable on this server") : "—"}
Assistant
- {caps ? (caps.llm_providers.length ? caps.llm_providers.join(", ") : "no provider configured") : "—"} + {caps ? (caps.llm_providers.length ? caps.llm_providers.join(", ") : "bring your own API key") : "—"}
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index b81f261..25f3ce5 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -345,13 +345,20 @@ export function decodeChatEvent(event: string, data: string): ChatEvent | null { } /** POST a user message and stream the assistant's events. */ +export interface ChatCredentials { + provider: "anthropic" | "openai"; + api_key: string; + model?: string; +} + export async function* streamChat( sessionId: string, content: string, signal?: AbortSignal, + credentials?: ChatCredentials, ): AsyncGenerator { const res = await request(`/chat/sessions/${encodeURIComponent(sessionId)}/messages`, { - json: { content }, + json: { content, ...credentials }, raw: true, accept: "text/event-stream", signal, diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 2b2c8dd..dd7be1f 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -11,6 +11,7 @@ export interface Capabilities { qpadm: boolean; llm_providers: string[]; llm_configured: boolean; + llm_byok?: boolean; reference_version: string | null; reference_loaded: boolean; snp_panel_loaded: boolean; diff --git a/services/api/strata/agent/providers.py b/services/api/strata/agent/providers.py index 7571dda..71ac9a2 100644 --- a/services/api/strata/agent/providers.py +++ b/services/api/strata/agent/providers.py @@ -101,6 +101,12 @@ class LLMProvider(ABC): def model(self) -> str: return self.settings.model + async def close(self) -> None: + """Release a request-scoped SDK client.""" + client = getattr(self, "_client", None) + if client is not None: + await client.close() + @abstractmethod def stream( self, @@ -133,7 +139,17 @@ async def _with_retries( attempt += 1 await asyncio.sleep(min(8.0, 0.5 * 2**attempt) + random.random() * 0.25) continue - yield ErrorEvent(message=f"{type(exc).__name__}: {exc}", retryable=retryable) + status = getattr(exc, "status_code", None) + message = { + 401: "The provider rejected your API key. Check or replace it and try again.", + 403: "Your API account cannot access this model. Choose an available model.", + 404: "The selected model is unavailable. Check the model name.", + 429: ( + "The provider's usage or rate limit was reached. " + "Check your API billing and retry later." + ), + }.get(status, f"{type(exc).__name__}: provider request failed. Please retry.") + yield ErrorEvent(message=message, retryable=retryable) return @@ -157,13 +173,21 @@ class AnthropicProvider(LLMProvider): name = "anthropic" - def __init__(self, settings: ProviderSettings | None = None, client: Any = None) -> None: + def __init__( + self, + settings: ProviderSettings | None = None, + client: Any = None, + *, + api_key: str | None = None, + ) -> None: self.settings = settings or ProviderSettings(model=DEFAULT_ANTHROPIC_MODEL) if client is None: import anthropic client = anthropic.AsyncAnthropic( - timeout=self.settings.timeout_s, max_retries=self.settings.sdk_max_retries + timeout=self.settings.timeout_s, + max_retries=self.settings.sdk_max_retries, + **({"api_key": api_key} if api_key else {}), ) self._client = client @@ -332,13 +356,21 @@ class OpenAIProvider(LLMProvider): name = "openai" - def __init__(self, settings: ProviderSettings | None = None, client: Any = None) -> None: + def __init__( + self, + settings: ProviderSettings | None = None, + client: Any = None, + *, + api_key: str | None = None, + ) -> None: self.settings = settings or ProviderSettings(model=DEFAULT_OPENAI_MODEL) if client is None: import openai client = openai.AsyncOpenAI( - timeout=self.settings.timeout_s, max_retries=self.settings.sdk_max_retries + timeout=self.settings.timeout_s, + max_retries=self.settings.sdk_max_retries, + **({"api_key": api_key} if api_key else {}), ) self._client = client @@ -491,7 +523,9 @@ def llm_available() -> dict[str, bool]: } -def get_provider(name: str | None = None, model: str | None = None) -> LLMProvider: +def get_provider( + name: str | None = None, model: str | None = None, *, api_key: str | None = None +) -> LLMProvider: """Construct the configured provider. Env: STRATA_LLM_PROVIDER (anthropic|openai), STRATA_LLM_MODEL, STRATA_LLM_MAX_TOKENS, @@ -511,7 +545,7 @@ def get_provider(name: str | None = None, model: str | None = None) -> LLMProvid fallbacks=os.environ.get("STRATA_ANTHROPIC_FALLBACKS", "on").lower() != "off", **common, ) - return AnthropicProvider(settings) + return AnthropicProvider(settings, api_key=api_key) if name == "openai": settings = ProviderSettings( model=model @@ -522,5 +556,5 @@ def get_provider(name: str | None = None, model: str | None = None) -> LLMProvid fallbacks=False, **common, ) - return OpenAIProvider(settings) + return OpenAIProvider(settings, api_key=api_key) raise ValueError(f"unknown LLM provider {name!r} (expected 'anthropic' or 'openai')") diff --git a/services/api/strata/api/routers/chat.py b/services/api/strata/api/routers/chat.py index 6ab9f5e..cbdbc1c 100644 --- a/services/api/strata/api/routers/chat.py +++ b/services/api/strata/api/routers/chat.py @@ -215,7 +215,7 @@ async def post_message( settings: SettingsDep, ) -> EventSourceResponse: chat = _owned(db, principal, session_id) - if not settings.llm_configured: + if not settings.llm_configured and body.api_key is None: raise ApiError( 503, f"research assistant unavailable: no API key configured for {settings.llm_provider}", @@ -226,6 +226,13 @@ async def post_message( except ImportError as exc: raise ApiError(503, f"agent module unavailable: {exc}", "llm_unavailable") from exc + # Never change process environment or persist credentials. Each BYOK request owns its client. + provider = ( + agent_mod.get_provider(body.provider, body.model, api_key=body.api_key.get_secret_value()) + if body.api_key is not None + else None + ) + prior = list( db.scalars( select(ChatMessage) @@ -263,7 +270,9 @@ async def events() -> AsyncIterator[dict[str, Any]]: error: str | None = None done: dict[str, Any] | None = None try: - async for ev in agent_mod.stream_chat(history, body.content, ctx=ctx, cancel=cancel): + async for ev in agent_mod.stream_chat( + history, body.content, ctx=ctx, cancel=cancel, provider=provider + ): if await request.is_disconnected(): cancel.set() t = ev.get("type") @@ -285,6 +294,8 @@ async def events() -> AsyncIterator[dict[str, Any]]: error = "client disconnected" raise finally: + if provider is not None: + await provider.close() msg_id = _persist( chat.id, principal.user_id, diff --git a/services/api/strata/api/routers/health.py b/services/api/strata/api/routers/health.py index b73e4fc..47ebc1b 100644 --- a/services/api/strata/api/routers/health.py +++ b/services/api/strata/api/routers/health.py @@ -45,7 +45,7 @@ def health(db: DbDep, settings: SettingsDep) -> Health: "(services/worker image)" ) if not settings.llm_configured: - notes.append(f"research assistant disabled: no API key for {settings.llm_provider}") + notes.append("research assistant uses visitor-supplied API keys (BYOK)") if not settings.data_key: notes.append("STRATA_DATA_KEY not set: using an ephemeral dev key (data lost on restart)") from strata.jobs.registry import kinds diff --git a/services/api/strata/api/schemas.py b/services/api/strata/api/schemas.py index 2c567a2..7cc42f8 100644 --- a/services/api/strata/api/schemas.py +++ b/services/api/strata/api/schemas.py @@ -6,7 +6,7 @@ from datetime import datetime from typing import Any, Generic, Literal, TypeVar -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator T = TypeVar("T") @@ -35,6 +35,7 @@ class Capabilities(Schema): qpadm: bool = Field(description="R + ADMIXTOOLS 2 available for formal qpAdm tests") llm_providers: list[str] = Field(description="LLM providers with a configured API key") llm_configured: bool = Field(description="The selected STRATA_LLM_PROVIDER has a key") + llm_byok: bool = True reference_version: str | None = Field(description="Loaded reference library version") reference_loaded: bool snp_panel_loaded: bool @@ -220,3 +221,16 @@ class ChatSessionDetail(ChatSessionOut): class ChatMessageCreate(Schema): content: str = Field(min_length=1, max_length=8000) + provider: Literal["anthropic", "openai"] = "anthropic" + model: str | None = Field(None, min_length=1, max_length=120) + api_key: SecretStr | None = Field(None, exclude=True, repr=False) + + @field_validator("api_key") + @classmethod + def validate_api_key(cls, value: SecretStr | None) -> SecretStr | None: + if value is not None: + raw = value.get_secret_value().strip() + if not 8 <= len(raw) <= 4096: + raise ValueError("API key must contain 8 to 4096 characters") + return SecretStr(raw) + return value diff --git a/services/api/strata/db/session.py b/services/api/strata/db/session.py index 1a5b532..bdfeaef 100644 --- a/services/api/strata/db/session.py +++ b/services/api/strata/db/session.py @@ -22,7 +22,7 @@ def make_engine(url: str, *, echo: bool = False) -> Engine: if url in ("sqlite://", "sqlite:///:memory:"): kwargs["poolclass"] = StaticPool else: - kwargs.update(pool_pre_ping=True, pool_size=10, max_overflow=10) + kwargs.update(pool_pre_ping=True, pool_size=3, max_overflow=2, pool_recycle=300) engine = create_engine(url, **kwargs) if url.startswith("sqlite"): diff --git a/services/api/strata/jobs/worker.py b/services/api/strata/jobs/worker.py index 5d46ab4..48892e1 100644 --- a/services/api/strata/jobs/worker.py +++ b/services/api/strata/jobs/worker.py @@ -132,6 +132,9 @@ def run_forever(self) -> int: def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser(prog="python -m strata.jobs.worker") p.add_argument("--once", action="store_true", help="run queued jobs until empty, then exit") + p.add_argument( + "--max-jobs", type=int, default=0, help="stop --once after this many jobs (0: unlimited)" + ) p.add_argument("--kinds", default="", help="comma separated job kinds to accept") p.add_argument( "--concurrency", type=int, default=int(os.environ.get("STRATA_WORKER_CONCURRENCY", "1")) @@ -148,8 +151,11 @@ def main(argv: list[str] | None = None) -> int: worker = Worker(engine, settings, kinds=kinds, concurrency=args.concurrency) if args.once: worker.maintenance() + completed = 0 while worker.run_one() and not worker.must_exit.is_set(): - pass + completed += 1 + if args.max_jobs > 0 and completed >= args.max_jobs: + break return EXIT_RESTART if worker.must_exit.is_set() else 0 signal.signal(signal.SIGTERM, worker.request_stop) signal.signal(signal.SIGINT, worker.request_stop) diff --git a/services/api/tests/api/test_byok.py b/services/api/tests/api/test_byok.py new file mode 100644 index 0000000..8a3a88e --- /dev/null +++ b/services/api/tests/api/test_byok.py @@ -0,0 +1,88 @@ +"""Request-scoped credentials must never enter the persisted chat audit.""" + +from __future__ import annotations + +import json + +import pytest + +from strata.api.schemas import ChatMessageCreate + + +def test_byok_isolated_and_not_persisted(client, make_guest, monkeypatch): + import strata.agent as agent + + clients = [] + + class Provider: + closed = False + + async def close(self): + self.closed = True + + def make_provider(name, model, *, api_key): + provider = Provider() + clients.append((name, model, api_key, provider)) + return provider + + async def stream(history, content, *, provider, **kwargs): + assert provider is clients[-1][3] + yield {"type": "text_delta", "text": "Test response"} + yield {"type": "done", "answer": "Test response", "citations": []} + + monkeypatch.setattr(agent, "get_provider", make_provider) + monkeypatch.setattr(agent, "stream_chat", stream) + headers = make_guest(client) + sid = client.post("/api/v1/chat/sessions", headers=headers, json={}).json()["id"] + for provider, key in (("openai", "test-key-first"), ("anthropic", "test-key-second")): + result = client.post( + f"/api/v1/chat/sessions/{sid}/messages", + headers=headers, + json={"content": "Test", "provider": provider, "api_key": key, "model": "test-model"}, + ) + assert result.status_code == 200, result.text + assert key not in result.text + assert [c[:3] for c in clients] == [ + ("openai", "test-model", "test-key-first"), + ("anthropic", "test-model", "test-key-second"), + ] + assert all(c[3].closed for c in clients) + history = client.get(f"/api/v1/chat/sessions/{sid}", headers=headers).text + assert "test-key" not in history and "api_key" not in history + assert client.get("/api/v1/health").json()["capabilities"]["llm_byok"] + + +def test_byok_secret_excluded_and_invalid_provider_rejected(client, make_guest): + body = ChatMessageCreate(content="hello", api_key="private-key-123") + assert "private-key-123" not in repr(body) + assert "api_key" not in body.model_dump() + headers = make_guest(client) + sid = client.post("/api/v1/chat/sessions", headers=headers, json={}).json()["id"] + response = client.post( + f"/api/v1/chat/sessions/{sid}/messages", + headers=headers, + json={"content": "hello", "provider": "other", "api_key": "private-key-123"}, + ) + assert response.status_code == 422 + assert "private-key-123" not in response.text + + +@pytest.mark.asyncio +async def test_provider_errors_do_not_expose_credentials(): + from strata.agent.providers import OpenAIProvider, ProviderSettings + + class RejectedError(Exception): + status_code = 401 + + class Completions: + async def create(self, **kwargs): + raise RejectedError("private-key-123") + + class Client: + chat = type("Chat", (), {"completions": Completions()})() + + provider = OpenAIProvider(ProviderSettings(model="test-model"), client=Client()) + events = [e async for e in provider.stream(system="test", tools=[], messages=[])] + text = json.dumps([e.model_dump(mode="json") for e in events]) + assert "private-key-123" not in text + assert "rejected your API key" in text diff --git a/services/worker/Dockerfile b/services/worker/Dockerfile index ca9fd68..7127dd7 100644 --- a/services/worker/Dockerfile +++ b/services/worker/Dockerfile @@ -44,7 +44,7 @@ ENV PATH=/opt/strata-r/bin:$PATH RUN git clone --quiet "${ADMIXTOOLS2_REPO}" /tmp/admixtools \ && git -C /tmp/admixtools checkout --quiet "${ADMIXTOOLS2_COMMIT}" \ && git -C /tmp/admixtools rev-parse HEAD > /opt/strata-r/ADMIXTOOLS2_COMMIT \ - && MAKEFLAGS="-j$(nproc)" R CMD INSTALL --no-docs --no-help /tmp/admixtools \ + && MAKEFLAGS="-j2" R CMD INSTALL --no-docs --no-help /tmp/admixtools \ && rm -rf /tmp/admixtools # Fail the build rather than ship an image whose f-statistics do not work. @@ -116,6 +116,23 @@ ENV PYTHONUNBUFFERED=1 \ STRATA_QPADM_TIMEOUT=3600 \ OMP_NUM_THREADS=1 +# The same self-contained image serves the API and bounded queue jobs on Azure. +# No downloads or writable reference volume are needed at startup. +COPY data/derived /app/data/derived +COPY data/examples /app/data/examples +COPY docs/methods /app/docs/methods +COPY data/pipeline/pipeline /app/data/pipeline/pipeline +COPY LICENSE /app/LICENSE +ENV PYTHONPATH=/app/data/pipeline \ + STRATA_DATA_DIR=/app/data \ + STRATA_EXAMPLES_DIR=/app/data/examples \ + STRATA_METHODS_DIR=/app/docs/methods \ + STRATA_F2_DIR=/app/data/derived/f2/strata-ref-v1 \ + OPENBLAS_NUM_THREADS=1 \ + MKL_NUM_THREADS=1 +RUN python -m pipeline --root /app fetch-f2 \ + && python -m pipeline --root /app verify + # infra/docker-compose.yml mounts data/derived here read-only. Create it so the # image also runs standalone, before any reference data exists. RUN mkdir -p /app/data/derived && chown -R strata:strata /app/data diff --git a/supabase/migrations/20261101104046_deployment_hardening.sql b/supabase/migrations/20261101104046_deployment_hardening.sql new file mode 100644 index 0000000..b025f24 --- /dev/null +++ b/supabase/migrations/20261101104046_deployment_hardening.sql @@ -0,0 +1,10 @@ +-- Trigger functions are internal; they must not be exposed as client RPCs. +revoke execute on function public.handle_new_user() from public, anon, authenticated; +alter function public.handle_new_user() set search_path = ''; + +-- Keep extension objects outside the exposed application schema. +create schema if not exists extensions; +alter extension vector set schema extensions; +grant usage on schema extensions to anon, authenticated; +alter function public.match_knowledge_chunks(extensions.vector, integer) + set search_path = public, extensions; From 58e3c550a7e15688e444aaaeab363267f0e66138 Mon Sep 17 00:00:00 2001 From: bharat <109189442+gothicscientist@users.noreply.github.com> Date: Sun, 1 Nov 2026 02:55:10 -0800 Subject: [PATCH 2/5] Retain R dependencies and verify the final runtime image --- services/worker/Dockerfile | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/services/worker/Dockerfile b/services/worker/Dockerfile index 7127dd7..af2a591 100644 --- a/services/worker/Dockerfile +++ b/services/worker/Dockerfile @@ -64,9 +64,9 @@ RUN Rscript -e 'pkgs <- c("admixtools","abind","cli","crayon","cubelyr","data.tr for (p in pkgs) cat(sprintf("%-16s %s\n", p, as.character(packageVersion(p))))' \ > /opt/strata-r/strata-r-versions.txt -# Drop the compiler toolchain from the layer that gets copied forward. -RUN micromamba remove -y -p /opt/strata-r gxx_linux-64 make || true \ - && micromamba clean --all --yes \ +# Do not ask the solver to remove compiler packages: conda-forge's dependency +# graph can remove R with them. Only discard archives and documentation. +RUN micromamba clean --all --yes \ && find /opt/strata-r -name '*.a' -delete \ && rm -rf /opt/strata-r/conda-meta/pkgs /opt/strata-r/share/doc @@ -139,6 +139,9 @@ RUN mkdir -p /app/data/derived && chown -R strata:strata /app/data USER strata +# Check the final filesystem as the runtime user, after all cleanup and copies. +RUN python -c "from strata.qpadm import qpadm_available; assert qpadm_available(), 'R runtime unavailable'" + # Liveness: the bridge resolves and ADMIXTOOLS answers. HEALTHCHECK --interval=60s --timeout=120s --start-period=30s --retries=3 \ CMD python -c "import sys; from strata.qpadm import qpadm_available; sys.exit(0 if qpadm_available() else 1)" From 069a6074be9d278d52ca263010264b88c911021c Mon Sep 17 00:00:00 2001 From: bharat <109189442+gothicscientist@users.noreply.github.com> Date: Sun, 1 Nov 2026 03:25:43 -0800 Subject: [PATCH 3/5] Add protected Azure deployment and live verification tooling --- README.md | 2 + infra/azure/README.md | 129 +++++++++++++++++ infra/azure/deploy.py | 260 +++++++++++++++++++++++++++++++++++ infra/azure/environment.json | 18 +++ infra/azure/verify_live.py | 236 +++++++++++++++++++++++++++++++ 5 files changed, 645 insertions(+) create mode 100644 infra/azure/README.md create mode 100644 infra/azure/deploy.py create mode 100644 infra/azure/environment.json create mode 100644 infra/azure/verify_live.py diff --git a/README.md b/README.md index 0d0298a..d610d85 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ Full stack: `docker compose -f infra/docker-compose.yml --env-file .env up --bui Production on Supabase, a container host and Cloudflare Pages: [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md). Every environment variable is documented in [`.env.example`](.env.example). +The credit-protected Azure/Cloudflare deployment for stratalab.cc is documented in +[`infra/azure/README.md`](infra/azure/README.md). ## Tests diff --git a/infra/azure/README.md b/infra/azure/README.md new file mode 100644 index 0000000..2a171c7 --- /dev/null +++ b/infra/azure/README.md @@ -0,0 +1,129 @@ +# stratalab.cc portfolio deployment + +The static Next.js export is on Cloudflare Pages (`strata-web`, assigned hostname +`strata-web-6ur.pages.dev`). The API and bounded queue workers use Azure Container +Apps in `westus2`, resource group `strata`, environment `strata-production`. +Supabase project `ghxzwjdjtepcwycgwknd` holds Postgres, private application data, +retention scheduling, and email authentication. Resend supplies SMTP. + +The API's managed HTTPS address is +`https://strata-api.delightfulflower-446608ec.westus2.azurecontainerapps.io`. +`api.stratalab.cc` also has a managed Azure certificate. The frontend uses the +managed address so fresh domain DNS caches do not delay API access. + +## Cost controls + +- Azure for Students subscription spending limit must remain **On**. The deployment + script refuses to proceed otherwise. Do not upgrade the subscription or remove + its protection. The claimed student credit expires November 1, 2027. +- API: Consumption profile, 1 CPU / 2 GiB, minimum 0 and maximum 1 replica. +- Worker: Consumption event job, 2 CPUs / 4 GiB, minimum 0 and maximum 1 execution, + one queue job per execution. The scaler polls every 30 seconds with a database + role that can only read queue status/timing columns. It also detects stale jobs + so a terminated execution can recover without another submission. +- No dedicated compute, paid container registry, or Log Analytics workspace is + created. Runtime images are public GHCR packages built from this public repo. +- Cloudflare, Supabase, and Resend use their free plans. Quotas and account + suspension/pausing can interrupt service; they are not permission to add a card + or upgrade a plan. The Azure credit is a finite allowance, not permanent hosting. + +An idle backend scales to zero. A new analysis normally waits for the queue poll +and a container start before computation begins; live checks took roughly 45–65 +seconds per small job. Workspaces and encrypted data persist in Postgres. + +## Runtime and secrets + +`services/worker/Dockerfile` builds Python, R, pinned ADMIXTOOLS 2, methods docs, +worked examples, the reference library/PCA model, and the verified f2 release. +The final image checks that R still works after cleanup, under the runtime user. +The API overrides its default worker entrypoint to run Uvicorn. Reference downloads +are performed at build time, not when a visitor opens the app. + +The local `.deployment/` directory and `apps/web/.env.local` are gitignored. +`.deployment/runtime.env` contains the runtime database connection and encryption +key. `.deployment/secrets.json` also contains the separate scaler connection and +role passwords. Back up these files privately, especially the data encryption key: +restoring the database alone cannot decrypt datasets without it. Never paste +their contents into a ticket, commit, browser frontend, or deployment log. + +Database roles are `strata_runtime` (API/worker application access, not superuser +and not BYPASSRLS) and `strata_scaler` (queue count only). The browser receives only +the Supabase publishable key. Client RLS and API owner checks protect user data; +dataset keys cannot be read by browser roles. + +No shared LLM key is installed. The assistant accepts an OpenAI or Anthropic key +for each request, creates a request-local provider, and closes it after streaming. +The key stays in page memory, is excluded from persisted messages and schemas, +and clears on reload/provider change or the Clear action. The selected provider +bills that visitor's account. Derived coordinates may go to the assistant; +genotype calls do not. + +## Reproduce deployment + +1. Apply `supabase/migrations/*.sql` to a dedicated Supabase project. Provision + the two backend roles separately; keep passwords outside migrations. Set the + auth Site URL to `https://stratalab.cc`, and allow only the exact owned workspace + callback URLs used by this deployment. Configure Resend SMTP with sending-only + access to `stratalab.cc`, sender `Strata `, host + `smtp.resend.com`, port 465, username `resend`, and the new Resend key as password. +2. Sign into Azure CLI in the protected student subscription. Create the standard + environment using `environment.json` and ARM API `2026-07-01` explicitly. The + `environmentMode: WorkloadProfiles` property is required: Express cannot host + the event job or the custom domain. Keep only the Consumption workload profile. +3. Push the deployment branch. `.github/workflows/runtime-image.yml` publishes + `ghcr.io/gothicscientist/strata-runtime:`. Use a public commit tag or + immutable digest; do not deploy the moving `portfolio` tag automatically. +4. Deploy with the authenticated CLI session: + + ```sh + python infra/azure/deploy.py \ + --subscription YOUR_PROTECTED_SUBSCRIPTION_ID \ + --image ghcr.io/gothicscientist/strata-runtime:COMMIT_SHA + ``` + + A uv-installed Azure CLI on Windows may require + `--az-python PATH/TO/azure-cli/Scripts/python.exe`. Normal browser login works + with the Berkeley tenant; device-code login is blocked by its access policy. + The script preserves existing hostname/certificate bindings during updates. +5. Set the frontend `NEXT_PUBLIC_API_URL`, `NEXT_PUBLIC_SUPABASE_URL`, and + `NEXT_PUBLIC_SUPABASE_ANON_KEY` in `apps/web/.env.local`. Run `pnpm build` in + `apps/web` and wait for completion. This produces CSP headers matching those + build-time origins. Then deploy the complete `out` directory: + + ```sh + wrangler pages deploy apps/web/out --project-name strata-web \ + --branch codex/deploy-stratalab-cc + ``` + +6. Bind `stratalab.cc` in Pages Custom domains. Cloudflare DNS should contain the + apex CNAME to the assigned Pages hostname, the `api` CNAME to Azure (DNS only), + the Azure `asuid.api` verification TXT, and Resend's DKIM/sending records. + `www` is proxied and redirected to the apex with its path/query preserved. + Nameservers at Spaceship are `anita.ns.cloudflare.com` and + `cris.ns.cloudflare.com`; registration stays at Spaceship. + +## Validation and maintenance + +Run `python infra/azure/verify_live.py --url API_HTTPS_URL` after a deployment. +It creates synthetic guest fixtures, exercises all six analysis kinds, queue +scaling, examples, reports, comparisons, and isolation, then deletes its test +datasets/analyses. It never uses a person's DNA or an LLM key. The verification +report goes to `.deployment/live-verification.json`. + +Email delivery/sign-in and a real billed AI tool call need separate browser +validation with the user's own inbox/key. A model provider mock passing does not +prove that the live account has credit or model access. + +The November 1 Linux image run passed 570 tests, with one expected skip for the +missing-R error path. Its 700k-row timing test missed the threshold during the +busy full run, then passed in isolation. Web typecheck, 51 tests, and the production +build passed. Supabase's security advisor returned no lints. These checks do not +replace subsequent live deployment checks. + +For rollback, deploy the last known-good immutable image tag to both API and worker, +and restore the previous Pages deployment. Preserve the encryption key and database. +Review Azure's remaining credit and each free-plan quota before they expire. + +The reference release has 193 populations, but its formal f2 cache covers 136. It +has no Andamanese-related/AASI proxy. The app must continue showing these limits; +deployment does not make unsupported population models scientifically identified. diff --git a/infra/azure/deploy.py b/infra/azure/deploy.py new file mode 100644 index 0000000..667e018 --- /dev/null +++ b/infra/azure/deploy.py @@ -0,0 +1,260 @@ +"""Deploy the portfolio API and zero-idle worker using an existing Azure CLI login. + +Secrets are read from a gitignored env file and sent directly to ARM over HTTPS. +The subscription must retain its spending limit. No registry or logging service +with an ongoing base charge is created. See README.md in this directory. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import time +import urllib.error +import urllib.request +from pathlib import Path + + +def main() -> None: + p = argparse.ArgumentParser() + p.add_argument("--subscription", required=True) + p.add_argument( + "--image", required=True, help="public GHCR image pinned to a commit/digest" + ) + p.add_argument("--env-file", default=".deployment/runtime.env") + p.add_argument("--secrets-file", default=".deployment/secrets.json") + p.add_argument("--resource-group", default="strata") + p.add_argument("--environment", default="strata-production") + p.add_argument("--location", default="westus2") + p.add_argument( + "--az-python", help="Python interpreter containing azure.cli (uv on Windows)" + ) + p.add_argument("--only", choices=["api", "worker"]) + a = p.parse_args() + az = [a.az_python, "-m", "azure.cli"] if a.az_python else ["az"] + token = json.loads( + subprocess.check_output( + az + + [ + "account", + "get-access-token", + "--subscription", + a.subscription, + "--resource", + "https://management.azure.com/", + "-o", + "json", + ] + ) + )["accessToken"] + + def arm( + method: str, path: str, body: dict | None = None, version="2024-03-01" + ) -> dict: + req = urllib.request.Request( + f"https://management.azure.com{path}?api-version={version}", + data=json.dumps(body).encode() if body is not None else None, + method=method, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=180) as response: + return json.load(response) + except urllib.error.HTTPError as exc: + # ARM validation errors do not contain secret values, but do not dump + # full requests or configurations, even on failure. + error = json.load(exc).get("error", {}) + raise RuntimeError( + f"ARM {exc.code}: {error.get('code')}: {error.get('message')}" + ) from None + + sub = arm("GET", f"/subscriptions/{a.subscription}", version="2022-12-01") + if sub.get("subscriptionPolicies", {}).get("spendingLimit") != "On": + raise SystemExit("Refusing deployment: subscription spending limit is not On") + root = f"/subscriptions/{a.subscription}/resourceGroups/{a.resource_group}/providers/Microsoft.App" + environment_id = f"{root}/managedEnvironments/{a.environment}" + environment = arm("GET", environment_id, version="2026-07-01") + if environment["properties"].get("environmentMode") != "WorkloadProfiles": + raise SystemExit( + "Use a WorkloadProfiles environment; Express cannot run jobs or custom domains" + ) + values = dict( + line.split("=", 1) + for line in Path(a.env_file).read_text().splitlines() + if line.strip() and not line.startswith("#") + ) + secret_values = json.loads(Path(a.secrets_file).read_text()) + refs = {"DATABASE_URL": "database-url", "STRATA_DATA_KEY": "data-key"} + secrets = [{"name": refs[k], "value": values[k]} for k in refs] + env = [ + {"name": k, **({"secretRef": refs[k]} if k in refs else {"value": v})} + for k, v in values.items() + ] + + container = { + "name": "api", + "image": a.image, + "env": env, + "command": ["python", "-m", "uvicorn"], + "args": [ + "strata.api.main:app", + "--host", + "0.0.0.0", + "--port", + "8000", + "--no-access-log", + "--no-proxy-headers", + ], + "resources": {"cpu": 1, "memory": "2Gi"}, + "probes": [ + { + "type": "Startup", + "tcpSocket": {"port": 8000}, + "periodSeconds": 5, + "failureThreshold": 60, + }, + { + "type": "Readiness", + "tcpSocket": {"port": 8000}, + "periodSeconds": 10, + "failureThreshold": 3, + }, + ], + } + api = { + "location": a.location, + "tags": {"project": "strata"}, + "properties": { + "managedEnvironmentId": environment_id, + "workloadProfileName": "Consumption", + "configuration": { + "activeRevisionsMode": "Single", + "secrets": secrets, + "ingress": { + "external": True, + "targetPort": 8000, + "transport": "auto", + "allowInsecure": False, + }, + }, + "template": { + "containers": [container], + "scale": { + "minReplicas": 0, + "maxReplicas": 1, + "rules": [ + { + "name": "http", + "http": {"metadata": {"concurrentRequests": "10"}}, + } + ], + }, + }, + }, + } + query = ( + "SELECT count(*) FROM public.jobs WHERE " + "(status='queued' AND run_after <= now()) OR " + "(status='running' AND heartbeat_at < now() - interval '120 seconds')" + ) + worker = { + "location": a.location, + "tags": {"project": "strata"}, + "properties": { + "environmentId": environment_id, + "workloadProfileName": "Consumption", + "configuration": { + "triggerType": "Event", + "replicaTimeout": 2100, + "replicaRetryLimit": 1, + "secrets": secrets + + [ + { + "name": "scaler-connection", + "value": secret_values["scaler_connection"], + } + ], + "eventTriggerConfig": { + "parallelism": 1, + "replicaCompletionCount": 1, + "scale": { + "minExecutions": 0, + "maxExecutions": 1, + "pollingInterval": 30, + "rules": [ + { + "name": "pending-jobs", + "type": "postgresql", + "metadata": {"query": query, "targetQueryValue": "1"}, + "auth": [ + { + "secretRef": "scaler-connection", + "triggerParameter": "connection", + } + ], + } + ], + }, + }, + }, + "template": { + "containers": [ + { + "name": "worker", + "image": a.image, + "env": env, + "command": ["python", "-m", "strata.jobs.worker"], + "args": ["--once", "--max-jobs", "1"], + "resources": {"cpu": 2, "memory": "4Gi"}, + } + ] + }, + }, + } + for kind, name, body in [ + ("api", "containerApps/strata-api", api), + ("worker", "jobs/strata-worker", worker), + ]: + if a.only and a.only != kind: + continue + path = f"{root}/{name}" + # Preserve certificate/domain bindings on later image updates. + if kind == "api": + try: + existing = arm("GET", path) + domains = ( + existing["properties"]["configuration"] + .get("ingress", {}) + .get("customDomains") + ) + if domains: + body["properties"]["configuration"]["ingress"]["customDomains"] = ( + domains + ) + except RuntimeError as exc: + if "ARM 404" not in str(exc): + raise + result = arm("PUT", path, body) + for _ in range(100): + state = result["properties"].get("provisioningState", "") + if state in {"Succeeded", "Failed", "Canceled"}: + break + time.sleep(10) + result = arm("GET", path) + print(f"{kind}: {state}", flush=True) + if state != "Succeeded": + raise SystemExit(f"{kind} did not provision successfully") + if kind == "api": + print( + "API hostname:", + result["properties"]["configuration"]["ingress"]["fqdn"], + flush=True, + ) + + +if __name__ == "__main__": + main() diff --git a/infra/azure/environment.json b/infra/azure/environment.json new file mode 100644 index 0000000..8962012 --- /dev/null +++ b/infra/azure/environment.json @@ -0,0 +1,18 @@ +{ + "location": "westus2", + "tags": { + "project": "strata" + }, + "properties": { + "appLogsConfiguration": { + "destination": null + }, + "environmentMode": "WorkloadProfiles", + "workloadProfiles": [ + { + "name": "Consumption", + "workloadProfileType": "Consumption" + } + ] + } +} \ No newline at end of file diff --git a/infra/azure/verify_live.py b/infra/azure/verify_live.py new file mode 100644 index 0000000..fd47138 --- /dev/null +++ b/infra/azure/verify_live.py @@ -0,0 +1,236 @@ +"""Exercise the deployed stack using clearly synthetic, disposable guest data. + +No user DNA or provider key is used. Writes a public-safe verification report; +guest credentials stay in memory. Run from the repository root after deployment. +""" + +from __future__ import annotations + +import argparse +import json +import time +from pathlib import Path + +import httpx +import numpy as np +import pandas as pd + + +def main() -> None: + p = argparse.ArgumentParser() + p.add_argument("--url", default="https://api.stratalab.cc") + p.add_argument("--output", default=".deployment/live-verification.json") + a = p.parse_args() + client = httpx.Client(base_url=a.url.rstrip("/") + "/api/v1/", timeout=180) + results: list[dict] = [] + datasets: list[str] = [] + analyses: list[dict] = [] + comparisons: list[str] = [] + sessions: list[str] = [] + + def request(method: str, path: str, expected=200, **kwargs): + r = client.request(method, path, **kwargs) + if r.status_code != expected: + raise AssertionError(f"{method} {path}: {r.status_code}: {r.text[:1200]}") + return r + + def record(name: str, **details) -> None: + results.append({"check": name, "passed": True, **details}) + Path(a.output).write_text( + json.dumps({"url": a.url, "results": results}, indent=2) + ) + print(name, "PASS", json.dumps(details), flush=True) + + health = request("GET", "health").json() + caps = health["capabilities"] + assert health["status"] == "ok" + assert all( + caps[k] for k in ["reference_loaded", "snp_panel_loaded", "qpadm", "llm_byok"] + ) + assert caps["job_mode"] == "queue" and caps["database"] == "postgres" + record("health", capabilities=caps) + for path in [ + "reference/populations", + "reference/spaces", + "reference/publications", + "reference/panel", + "examples", + ]: + assert request("GET", path).json() + examples = request("GET", "examples").json() + for example in examples: + assert request("GET", f"examples/{example['slug']}").json()["result"] + record("reference library and all worked examples", examples=len(examples)) + guest = request("POST", "auth/guest", 201).json() + client.headers["Authorization"] = f"Bearer {guest['token']}" + other = httpx.Client(base_url=client.base_url, timeout=180) + other_guest = other.post("auth/guest").json() + other.headers["Authorization"] = f"Bearer {other_guest['token']}" + assert request("GET", "auth/me").json()["kind"] == "guest" + + def upload(name: str, text: str, kind="g25") -> dict: + ds = request( + "POST", + "datasets", + 201, + data={"name": "DEPLOYMENT TEST — " + name, "kind": kind}, + files={"file": (name + (".csv" if kind == "g25" else ".txt"), text)}, + ).json() + datasets.append(ds["id"]) + return ds + + def analyse( + kind: str, params: dict, dataset: str | None = None, target: str | None = None + ) -> dict: + start = time.monotonic() + created = request( + "POST", + "analyses", + 201, + json={ + "kind": kind, + "title": "DEPLOYMENT TEST — " + kind, + "dataset_id": dataset, + "target": target, + "params": params, + }, + ).json() + analysis = created["analysis"] + analyses.append(analysis) + while time.monotonic() - start < 900: + analysis = request("GET", f"analyses/{analysis['id']}").json() + if analysis["status"] in {"succeeded", "failed", "cancelled"}: + break + time.sleep(5) + assert analysis["status"] == "succeeded", analysis.get( + "error", analysis["status"] + ) + assert analysis["result"] and analysis["provenance"]["reference_version"] + assert other.get(f"analyses/{analysis['id']}").status_code == 404 + bundle = request("GET", f"analyses/{analysis['id']}/report?format=json").json() + from strata.reports import verify_bundle + + assert verify_bundle(bundle) + assert ( + request("GET", f"analyses/{analysis['id']}/report") + .headers["content-type"] + .startswith("text/html") + ) + record( + kind, + seconds=round(time.monotonic() - start, 1), + classification=analysis["result"]["kind"], + analysis_id=analysis["id"], + ) + return analysis + + try: + rng = np.random.default_rng(20261101) + source_vectors = rng.normal(0, 0.02, (2, 25)) + + def line(name, vector): + return name + "," + ",".join(f"{x:.9f}" for x in vector) + + source = upload( + "synthetic-sources", + "\n".join( + line(f"SyntheticSource{i}", v) for i, v in enumerate(source_vectors) + ), + ) + target = upload( + "synthetic-target", + line("SyntheticTarget", 0.6 * source_vectors[0] + 0.4 * source_vectors[1]), + ) + assert other.get(f"datasets/{target['id']}").status_code == 404 + request("GET", f"datasets/{target['id']}") + record("G25 uploads and owner isolation") + fit = analyse( + "admixture_fit", + {"source_dataset_id": source["id"], "bootstrap": 0, "seed": 1}, + target["id"], + ) + assert abs(fit["result"]["weights"][0]["weight"] - 0.6) < 0.001 + rotate = analyse( + "admixture_rotate", + {"source_dataset_id": source["id"], "k_min": 1, "k_max": 2}, + target["id"], + ) + comparison = request( + "POST", + "comparisons", + 201, + json={"name": "DEPLOYMENT TEST", "analysis_ids": [fit["id"], rotate["id"]]}, + ).json() + comparisons.append(comparison["id"]) + assert any( + c["id"] == comparison["id"] and c["summary"] + for c in request("GET", "comparisons").json() + ) + assert not other.get("comparisons").json() + assert other.delete(f"comparisons/{comparison['id']}").status_code == 404 + record("comparison and report bundles") + + panel = pd.read_parquet("data/derived/snp_panel.parquet").iloc[::5].head(30000) + lines = [ + "# This is synthetic deployment-test data, not a person's genome.", + "# 23andMe raw data, genome build 37", + "# rsid\tchromosome\tposition\tgenotype", + ] + for row in panel.itertuples(): + genotype = row.ref + row.alt + lines.append(f"{row.rsid}\t{row.chrom}\t{row.pos}\t{genotype}") + raw = upload("synthetic-raw-dna", "\n".join(lines), "raw_dna") + assert raw["summary"]["harmonized"] + assert raw["qc"]["overlap"]["n_usable"] > 10000 + projected = analyse("pca_project", {}, raw["id"]) + assert projected["result"]["n_snps_used"] > 10000 + analyse("nearest", {"k": 5}, raw["id"]) + record( + "raw DNA harmonization and projection", + usable_snps=raw["qc"]["overlap"]["n_usable"], + ) + + right = ["Mbuti", "Han", "Papuan", "Mixe", "Russia_UstIshim_IUP"] + analyse( + "qpadm", + {"left": ["Iran_GanjDareh_N"], "right": right}, + target="Iran_HajjiFiruz_N", + ) + analyse( + "qpadm_rotate", + { + "candidates": ["Iran_GanjDareh_N", "Iran_SehGabi_C"], + "right": right, + "min_sources": 1, + "max_sources": 1, + }, + target="Iran_HajjiFiruz_N", + ) + session = request( + "POST", "chat/sessions", 201, json={"title": "DEPLOYMENT TEST"} + ).json() + sessions.append(session["id"]) + assert request("GET", f"chat/sessions/{session['id']}").json() + assert other.get(f"chat/sessions/{session['id']}").status_code == 404 + missing_key = client.post( + f"chat/sessions/{session['id']}/messages", json={"content": "test"} + ) + assert missing_key.status_code == 503 + record("chat sessions and explicit BYOK requirement") + finally: + for sid in sessions: + request("DELETE", f"chat/sessions/{sid}", 204) + for cid in comparisons: + request("DELETE", f"comparisons/{cid}", 204) + for analysis in analyses: + request("DELETE", f"analyses/{analysis['id']}", 204) + for did in datasets: + request("DELETE", f"datasets/{did}", 204) + assert client.get(f"datasets/{did}").status_code == 404 + record("synthetic test data deleted") + other.close() + client.close() + + +if __name__ == "__main__": + main() From 4ae5fbf4a806055b514bdbf89aa62e9edf46b075 Mon Sep 17 00:00:00 2001 From: bharat <109189442+gothicscientist@users.noreply.github.com> Date: Sun, 1 Nov 2026 03:30:13 -0800 Subject: [PATCH 4/5] Correct reference overlap count and coverage in upload QC --- apps/web/src/components/QcReport.tsx | 8 ++++---- apps/web/src/lib/types.ts | 2 ++ apps/web/test/components.test.tsx | 14 +++++++++++++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/QcReport.tsx b/apps/web/src/components/QcReport.tsx index 2b010ea..7bf0952 100644 --- a/apps/web/src/components/QcReport.tsx +++ b/apps/web/src/components/QcReport.tsx @@ -12,7 +12,7 @@ const KNOWN: Array<[keyof Dataset["qc"], string, (v: unknown) => string]> = [ ["n_no_calls", "No-calls", (v) => int(v as number)], ["n_indels_dropped", "Indels dropped", (v) => int(v as number)], ["het_rate", "Heterozygosity", (v) => fixed(v as number, 4)], - ["panel_overlap", "SNPs on reference panel", (v) => int(v as number)], + ["panel_overlap", "Panel coverage", (v) => pct(v as number, 1)], ["panel_size", "Reference panel size", (v) => int(v as number)], ]; @@ -24,11 +24,11 @@ export function QcReport({ dataset, n = 1 }: { dataset: Dataset; n?: number | st const v = qc[key]; if (v === null || v === undefined || v === "") continue; if (key === "n_variants" && dataset.kind === "g25") continue; + if (key === "panel_overlap" && typeof qc.overlap?.n_usable === "number") { + rows.push(["SNPs on reference panel", int(qc.overlap.n_usable)]); + } rows.push([label, fmt(v)]); } - if (qc.panel_overlap && qc.panel_size) { - rows.push(["Panel coverage", pct(qc.panel_overlap / qc.panel_size, 1)]); - } if (qc.sex_chrom_counts) { rows.push([ "Sex-chromosome calls", diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index dd7be1f..3c97f46 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -181,8 +181,10 @@ export interface QCReport { n_indels_dropped?: number | null; het_rate?: number | null; sex_chrom_counts?: Record | null; + /** Fraction of reference-panel SNPs usable, between 0 and 1. */ panel_overlap?: number | null; panel_size?: number | null; + overlap?: { n_usable?: number; [key: string]: unknown } | null; n_samples?: number | null; warnings: string[]; [key: string]: unknown; diff --git a/apps/web/test/components.test.tsx b/apps/web/test/components.test.tsx index 49bd55b..b953497 100644 --- a/apps/web/test/components.test.tsx +++ b/apps/web/test/components.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import { QcReport } from "@/components/QcReport"; import { ResultView } from "@/components/results/ResultView"; import { ErrorNote, KindMark } from "@/components/typography"; @@ -18,6 +18,18 @@ describe("components", () => { expect(screen.getByText("synthetic warning")).toBeInTheDocument(); }); + it.each([ + { usable: 30_000, fraction: 30_000 / 417_935, count: /30\s000/, coverage: "7.2%" }, + { usable: 0, fraction: 0, count: /^0$/, coverage: "0.0%" }, + ])("shows the API's SNP count and coverage separately ($usable usable)", ({ usable, fraction, count, coverage }) => { + render(); + expect(within(screen.getByRole("row", { name: /SNPs on reference panel/ })).getByRole("cell", { name: count })).toBeInTheDocument(); + expect(screen.getByRole("row", { name: /Panel coverage/ })).toHaveTextContent(coverage); + }); + it("labels results as exploratory or formal", () => { render(); expect(screen.getByText("formal")).toHaveAttribute("title", expect.stringMatching(/qpAdm/)); From 2f88464e34944450d8ec7966135aaf803dc47cb2 Mon Sep 17 00:00:00 2001 From: bharat <109189442+gothicscientist@users.noreply.github.com> Date: Sun, 1 Nov 2026 03:40:12 -0800 Subject: [PATCH 5/5] Record deployed source revision and contain report metadata --- apps/web/src/components/results/AnalysisDocument.tsx | 8 ++++---- infra/azure/README.md | 9 ++++++++- infra/azure/deploy.py | 11 +++++++++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/results/AnalysisDocument.tsx b/apps/web/src/components/results/AnalysisDocument.tsx index d7c9383..dab4c48 100644 --- a/apps/web/src/components/results/AnalysisDocument.tsx +++ b/apps/web/src/components/results/AnalysisDocument.tsx @@ -72,11 +72,11 @@ export function ReportButtons({ id }: { id: string }) { export function ProvenanceList({ provenance }: { provenance: Record | null | undefined }) { if (!provenance || Object.keys(provenance).length === 0) return null; return ( -
+
{describeParams(provenance).map(([k, v]) => (
{k}
-
{v}
+
{v}
))}
@@ -109,11 +109,11 @@ export function AnalysisDocument({ analysis }: { analysis: Analysis }) {
{params.length ? ( -
+
{params.map(([k, v]) => (
{k}
-
{v}
+
{v}
))}
diff --git a/infra/azure/README.md b/infra/azure/README.md index 2a171c7..fb9073a 100644 --- a/infra/azure/README.md +++ b/infra/azure/README.md @@ -73,6 +73,8 @@ genotype calls do not. 3. Push the deployment branch. `.github/workflows/runtime-image.yml` publishes `ghcr.io/gothicscientist/strata-runtime:`. Use a public commit tag or immutable digest; do not deploy the moving `portfolio` tag automatically. + For a digest, pass its source commit with `--git-sha`. Commit tags supply the + source revision automatically so downloaded reports identify the deployed code. 4. Deploy with the authenticated CLI session: ```sh @@ -116,10 +118,15 @@ prove that the live account has credit or model access. The November 1 Linux image run passed 570 tests, with one expected skip for the missing-R error path. Its 700k-row timing test missed the threshold during the -busy full run, then passed in isolation. Web typecheck, 51 tests, and the production +busy full run, then passed in isolation. Web typecheck, 53 tests, and the production build passed. Supabase's security advisor returned no lints. These checks do not replace subsequent live deployment checks. +Live checks also covered ZIP/gzip, VCF, 23andMe, AncestryDNA, FTDNA, MyHeritage, +queued-job cancellation, and deletion. Browser validation covered raw upload, +QC, queued PCA, axis selection, persisted workspaces, and HTML/JSON downloads. +The QC display distinguishes the exact usable-SNP count from the coverage fraction. + For rollback, deploy the last known-good immutable image tag to both API and worker, and restore the previous Pages deployment. Preserve the encryption key and database. Review Azure's remaining credit and each free-plan quota before they expire. diff --git a/infra/azure/deploy.py b/infra/azure/deploy.py index 667e018..9577b2c 100644 --- a/infra/azure/deploy.py +++ b/infra/azure/deploy.py @@ -9,6 +9,7 @@ import argparse import json +import re import subprocess import time import urllib.error @@ -23,6 +24,10 @@ def main() -> None: "--image", required=True, help="public GHCR image pinned to a commit/digest" ) p.add_argument("--env-file", default=".deployment/runtime.env") + p.add_argument( + "--git-sha", + help="source commit for a digest-pinned image (commit tags infer it)", + ) p.add_argument("--secrets-file", default=".deployment/secrets.json") p.add_argument("--resource-group", default="strata") p.add_argument("--environment", default="strata-production") @@ -87,6 +92,12 @@ def arm( for line in Path(a.env_file).read_text().splitlines() if line.strip() and not line.startswith("#") ) + source_sha = a.git_sha or a.image.rsplit(":", 1)[-1] + if not re.fullmatch(r"[0-9a-f]{40}", source_sha): + raise SystemExit( + "Use a full commit image tag, or provide --git-sha for report provenance" + ) + values["STRATA_GIT_SHA"] = source_sha secret_values = json.loads(Path(a.secrets_file).read_text()) refs = {"DATABASE_URL": "database-url", "STRATA_DATA_KEY": "data-key"} secrets = [{"name": refs[k], "value": values[k]} for k in refs]