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/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/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/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/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/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/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..3c97f46 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; @@ -180,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/)); diff --git a/infra/azure/README.md b/infra/azure/README.md new file mode 100644 index 0000000..fb9073a --- /dev/null +++ b/infra/azure/README.md @@ -0,0 +1,136 @@ +# 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. + 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 + 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, 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. + +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..9577b2c --- /dev/null +++ b/infra/azure/deploy.py @@ -0,0 +1,271 @@ +"""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 re +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( + "--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") + 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("#") + ) + 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] + 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() 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..af2a591 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. @@ -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 @@ -116,12 +116,32 @@ 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 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)" 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;