diff --git a/api/.env.example b/api/.env.example index 168f354..fa1601e 100644 --- a/api/.env.example +++ b/api/.env.example @@ -1,9 +1,10 @@ USE_MOCK=true -GEMINI_API_KEY=your-gemini-key-here +GOOGLE_CLOUD_PROJECT=b2-beacon1 +GOOGLE_CLOUD_LOCATION=us-east1 RATE_LIMIT=5/minute;60/day # Comma-separated Origin allowlist. Leave empty in dev to accept everything. # In production set it to the published extension origin, e.g.: # ALLOWED_EXTENSION_ORIGINS=chrome-extension://abcdefghijklmnopabcdefghijklmnop ALLOWED_EXTENSION_ORIGINS= # GEMINI_MODEL=gemini-2.5-flash-lite -# FORWARDED_ALLOW_IPS= \ No newline at end of file +# FORWARDED_ALLOW_IPS= diff --git a/api/Dockerfile b/api/Dockerfile index 6a328f9..4457921 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -12,4 +12,6 @@ EXPOSE 3000 # --proxy-headers makes uvicorn trust X-Forwarded-For from the proxy in front # of it, so per-client rate limiting keys on the real client IP instead of the # proxy's. Set FORWARDED_ALLOW_IPS to the proxy's IP/CIDR in production. -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3000", "--proxy-headers"] +# Shell-form CMD so ${PORT} expands: Cloud Run injects PORT and expects the +# container to listen on it; locally PORT is unset and we default to 3000. +CMD uvicorn main:app --host 0.0.0.0 --port ${PORT:-3000} --proxy-headers diff --git a/api/README.md b/api/README.md index 70cc51b..22865c0 100644 --- a/api/README.md +++ b/api/README.md @@ -29,8 +29,9 @@ Nothing is ever inverted on the wire. | Variable | Required | Description | |---|---|---| -| `USE_MOCK` | No (default: `true`) | Skip Gemini and use a deterministic mock. No API key needed. | -| `GEMINI_API_KEY` | Only if `USE_MOCK=false` | Gemini API key for live LLM analysis. Server-side only — never ships to clients. | +| `USE_MOCK` | No (default: `true`) | Skip Gemini and use a deterministic mock. No credentials needed. | +| `GOOGLE_CLOUD_PROJECT` | Only if `USE_MOCK=false` | GCP project for Vertex AI (`b2-beacon1`). Auth is via ADC / the runtime service account — no API key. | +| `GOOGLE_CLOUD_LOCATION` | No (default: `us-east1`) | Vertex AI region. | | `GEMINI_MODEL` | No (default: `gemini-2.5-flash-lite`) | Gemini model name. Override to migrate to a newer model without a code change. | | `ALLOWED_EXTENSION_ORIGINS` | Recommended in production | Comma-separated `Origin` allowlist, e.g. `chrome-extension://`. Also used as the CORS allowlist. Empty = accept all origins (dev; a warning is logged). | | `RATE_LIMIT` | No (default: `5/minute;60/day`) | Per-IP limits, `;`-separated (e.g. `10/minute;100/day`). | @@ -42,5 +43,4 @@ Nothing is ever inverted on the wire. - `POST /v1/analyze` — Tier 2 analysis. Request/response schemas in `schemas.py`. - `GET /health` — liveness probe, no auth, no rate limit. - -> **Tip:** Keep `USE_MOCK=true` while developing. Switch to `USE_MOCK=false` only when you need a real Gemini response. +``` \ No newline at end of file diff --git a/api/cloudbuild.yaml b/api/cloudbuild.yaml new file mode 100644 index 0000000..7e5f684 --- /dev/null +++ b/api/cloudbuild.yaml @@ -0,0 +1,66 @@ +# Cloud Build pipeline: build the Tier-2 API image, push it to Artifact +# Registry, and deploy it to Cloud Run on b2-beacon1. +# +# The Cloud Run service runs AS beacon-app-service, so Vertex AI calls +# authenticate through that service account automatically — no API key, no +# mounted key file. Config is injected via --set-env-vars (secrets in .env are +# excluded from the image by .dockerignore). +# +# Submit from the api/ directory so the build context is this folder: +# gcloud builds submit --config cloudbuild.yaml \ +# --substitutions=_ALLOWED_ORIGINS=chrome-extension:// +# +# Prerequisites (grant once, out of band): +# - roles/aiplatform.user on beacon-app-service@b2-beacon1 (call Vertex) +# - the Cloud Build SA needs roles/run.admin, roles/artifactregistry.writer, +# and roles/iam.serviceAccountUser on beacon-app-service (deploy as it) +# - an Artifact Registry repo named ${_AR_REPO} must exist in ${_REGION} + +substitutions: + _REGION: us-east1 + _SERVICE: beacon-api + _AR_REPO: beacon + _RUNTIME_SA: beacon-app-service@b2-beacon1.iam.gserviceaccount.com + _TAG: latest + # Published extension origin, e.g. chrome-extension://. Empty accepts all + # origins (dev only). A single origin — commas would clash with the env-var + # delimiter below. + _ALLOWED_ORIGINS: "" + +steps: + # 1. Build the container image. + - name: gcr.io/cloud-builders/docker + args: + - build + - -t + - ${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_AR_REPO}/${_SERVICE}:${_TAG} + - . + + # 2. Push it to Artifact Registry. + - name: gcr.io/cloud-builders/docker + args: + - push + - ${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_AR_REPO}/${_SERVICE}:${_TAG} + + # 3. Deploy to Cloud Run as the beacon-app-service service account. + # Public (--allow-unauthenticated) by design: there is no client secret; + # abuse control is the Origin allowlist + per-IP rate limit (see auth.py). + - name: gcr.io/google.com/cloudsdktool/cloud-sdk + entrypoint: gcloud + args: + - run + - deploy + - ${_SERVICE} + - --image=${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_AR_REPO}/${_SERVICE}:${_TAG} + - --platform=managed + - --region=${_REGION} + - --service-account=${_RUNTIME_SA} + - --allow-unauthenticated + - --port=3000 + - --set-env-vars=USE_MOCK=false,GOOGLE_CLOUD_PROJECT=${PROJECT_ID},GOOGLE_CLOUD_LOCATION=${_REGION},GEMINI_MODEL=gemini-2.5-flash-lite,ALLOWED_EXTENSION_ORIGINS=${_ALLOWED_ORIGINS},FORWARDED_ALLOW_IPS=* + +images: + - ${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_AR_REPO}/${_SERVICE}:${_TAG} + +options: + logging: CLOUD_LOGGING_ONLY diff --git a/api/main.py b/api/main.py index 437f6fa..c9ee699 100644 --- a/api/main.py +++ b/api/main.py @@ -36,8 +36,9 @@ def _validate_config() -> None: "extension origin (chrome-extension://) before deploying." ) - # Eagerly initialise the provider so a missing GEMINI_API_KEY fails here - # with a clear message rather than producing a 503 on the first request. + # Eagerly initialise the provider so missing Vertex config (e.g. + # GOOGLE_CLOUD_PROJECT) fails here with a clear message rather than + # producing a 503 on the first request. get_provider() _validate_config() diff --git a/api/providers/gemini_provider.py b/api/providers/gemini_provider.py index a818cc3..fe9c1fc 100644 --- a/api/providers/gemini_provider.py +++ b/api/providers/gemini_provider.py @@ -50,12 +50,21 @@ def build_prompt(req: AnalyzeRequest) -> str: class GeminiProvider: def __init__(self): - api_key = os.getenv("GEMINI_API_KEY", "") - if not api_key: + # b2 does not use API keys — Gemini is reached through Vertex AI, which + # authenticates with Application Default Credentials. Locally that comes + # from `gcloud auth application-default login --impersonate-service-account= + # beacon-app-service@b2-beacon1.iam.gserviceaccount.com`; on Cloud Run it + # is the attached beacon-app-service account automatically. No key is read + # or stored anywhere. + project = os.getenv("GOOGLE_CLOUD_PROJECT", "") + location = os.getenv("GOOGLE_CLOUD_LOCATION", "us-east1") + if not project: raise ProviderConfigError( - "GEMINI_API_KEY is not set. Set it in api/.env or the environment, or set USE_MOCK=true." + "GOOGLE_CLOUD_PROJECT is not set. Set it in api/.env and run " + "`gcloud auth application-default login --impersonate-service-account=" + "beacon-app-service@b2-beacon1.iam.gserviceaccount.com`, or set USE_MOCK=true." ) - self.client = genai.Client(api_key=api_key) + self.client = genai.Client(vertexai=True, project=project, location=location) async def analyze(self, request: AnalyzeRequest) -> AnalyzeResponse: response = await self.client.aio.models.generate_content( diff --git a/api/requirements.txt b/api/requirements.txt index 4608795..e80e7c9 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -2,4 +2,4 @@ fastapi>=0.115 uvicorn[standard]>=0.30 python-dotenv>=1.0 slowapi>=0.1.9 -google-genai>=1.0 +google-genai==2.8.0 diff --git a/api/tests/test_provider_config.py b/api/tests/test_provider_config.py new file mode 100644 index 0000000..831afec --- /dev/null +++ b/api/tests/test_provider_config.py @@ -0,0 +1,17 @@ +# Provider configuration guards. +# +# Constructing the real (Vertex) provider without the required config must fail +# fast with ProviderConfigError — not a network/auth error later — so a +# misconfigured deploy is caught at startup by _validate_config(). No network is +# touched: the guard raises before any Vertex client is built. + +import pytest + +from providers.gemini_provider import GeminiProvider +from exceptions import ProviderConfigError + + +def test_missing_project_raises_config_error(monkeypatch): + monkeypatch.delenv("GOOGLE_CLOUD_PROJECT", raising=False) + with pytest.raises(ProviderConfigError): + GeminiProvider()