From 6fe2e7b6c336e2ece592834636988299103564e1 Mon Sep 17 00:00:00 2001 From: qzl Date: Mon, 9 Mar 2026 18:03:04 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E8=BF=81=E7=A7=BB=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=20Supabase=20=E5=88=B0=E4=BA=91=E7=AB=AF=EF=BC=8C?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=20JWKS=20=E8=BF=9B=E8=A1=8C=20JWT=20?= =?UTF-8?q?=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 JwtVerifier 支持 RS256 + JWKS 验证 - 简化 docker-compose,删除本地 Supabase 服务(kong/auth/storage等) - 删除冗余的 Supabase 配置文件(volumes目录) - 适配测试用例以支持新配置方式 - 更新运行时文档和迁移计划 --- .env.example | 87 +--- .github/workflows/manual-live-e2e.yml | 76 ---- .opencode/opencode.json | 17 +- backend/src/core/auth/jwt_verifier.py | 52 +++ backend/src/core/config/settings.py | 44 +- backend/src/v1/users/dependencies.py | 67 ++- .../v1/agent/test_sse_flow_live.py | 65 +-- .../tests/unit/core/auth/test_jwt_verifier.py | 268 ++++++++++++ .../tests/unit/test_settings_supabase_env.py | 70 ++- ...3-09-cloud-supabase-jwks-migration-plan.md | 303 +++++++++++++ docs/runtime/runtime-runbook.md | 40 +- infra/docker/docker-compose.yml | 411 +----------------- infra/docker/volumes/api/kong.yml | 238 ---------- infra/docker/volumes/db/_supabase.sql | 2 - infra/docker/volumes/db/jwt.sql | 4 - infra/docker/volumes/db/logs.sql | 5 - infra/docker/volumes/db/pooler.sql | 5 - infra/docker/volumes/db/realtime.sql | 3 - infra/docker/volumes/db/roles.sql | 7 - infra/docker/volumes/db/webhooks.sql | 191 -------- infra/docker/volumes/functions/main/index.ts | 5 - infra/docker/volumes/logs/vector.yml | 237 ---------- infra/docker/volumes/pooler/pooler.exs | 29 -- infra/scripts/dev-migrate.sh | 2 +- 24 files changed, 825 insertions(+), 1403 deletions(-) delete mode 100644 .github/workflows/manual-live-e2e.yml create mode 100644 backend/src/core/auth/jwt_verifier.py create mode 100644 backend/tests/unit/core/auth/test_jwt_verifier.py create mode 100644 docs/plans/2026-03-09-cloud-supabase-jwks-migration-plan.md delete mode 100644 infra/docker/volumes/api/kong.yml delete mode 100644 infra/docker/volumes/db/_supabase.sql delete mode 100644 infra/docker/volumes/db/jwt.sql delete mode 100644 infra/docker/volumes/db/logs.sql delete mode 100644 infra/docker/volumes/db/pooler.sql delete mode 100644 infra/docker/volumes/db/realtime.sql delete mode 100644 infra/docker/volumes/db/roles.sql delete mode 100644 infra/docker/volumes/db/webhooks.sql delete mode 100644 infra/docker/volumes/functions/main/index.ts delete mode 100644 infra/docker/volumes/logs/vector.yml delete mode 100644 infra/docker/volumes/pooler/pooler.exs diff --git a/.env.example b/.env.example index 62f6d69..dbad8ba 100644 --- a/.env.example +++ b/.env.example @@ -19,7 +19,7 @@ SOCIAL_WEB__WORKERS=2 ############ # Redis 配置 ############ -SOCIAL_REDIS__PASSWORD=change-me-redis-password +SOCIAL_REDIS__PASSWORD=redis-secure-2026 SOCIAL_REDIS__HOST=localhost SOCIAL_REDIS__PORT=6379 SOCIAL_REDIS__DB=0 @@ -43,20 +43,14 @@ SOCIAL_WORKER__GROUPS__BULK__CONCURRENCY=1 # SOCIAL_TASKIQ__RESULT_BACKEND_URL=redis://:password@localhost:6379/0 ############ -# Supabase(本地 Docker 与阿里云自托管保持同一变量) +# Supabase(云模式,后端必需) ############ -# Supabase 栈使用 infra/docker/docker-compose.yml -# 仅绑定 127.0.0.1,不对局域网/公网暴露 +SOCIAL_SUPABASE__PUBLIC_URL=https://your-project.supabase.co +SOCIAL_SUPABASE__ANON_KEY= +SOCIAL_SUPABASE__SERVICE_ROLE_KEY= -# 基础 URL(本地默认 8000) -SOCIAL_SUPABASE__PUBLIC_SCHEME=http -SOCIAL_SUPABASE__PUBLIC_HOST=localhost -SOCIAL_SUPABASE__SITE_URL=http://localhost:3000 - -####### -# 本地 Supabase 端口(只绑定 127.0.0.1) -SOCIAL_SUPABASE__KONG_HTTP_PORT=8000 -SOCIAL_SUPABASE__KONG_HTTPS_PORT=8443 +# Cloud Auth 可选配置(默认值已满足大多数场景) +SOCIAL_SUPABASE__JWT_AUDIENCE=authenticated # Postgres 连接信息(后端与 Supabase 共用密码) SOCIAL_DATABASE__HOST=localhost @@ -65,70 +59,9 @@ SOCIAL_DATABASE__NAME=postgres SOCIAL_DATABASE__USER=postgres SOCIAL_DATABASE__PASSWORD=change-me-strong-password -# JWT/Keys(必须替换) -SOCIAL_SUPABASE__JWT_SECRET=change-me-jwt-secret-at-least-32-chars -SOCIAL_SUPABASE__ANON_KEY=replace-with-supabase-anon-key -SOCIAL_SUPABASE__SERVICE_ROLE_KEY=replace-with-supabase-service-role-key - -# Studio 登录 -SOCIAL_SUPABASE__DASHBOARD_USERNAME=admin -SOCIAL_SUPABASE__DASHBOARD_PASSWORD=change-me - -# 核心加密 Key(必须替换) -SOCIAL_SUPABASE__SECRET_KEY_BASE=change-me-secret-key-base -SOCIAL_SUPABASE__VAULT_ENC_KEY=change-me-vault-enc-key -SOCIAL_SUPABASE__PG_META_CRYPTO_KEY=change-me-pg-meta-crypto-key - -####### -# Logflare(本地可用假值,但不要上云) -SOCIAL_SUPABASE__LOGFLARE_PUBLIC_ACCESS_TOKEN=change-me-logflare-public -SOCIAL_SUPABASE__LOGFLARE_PRIVATE_ACCESS_TOKEN=change-me-logflare-private - -####### -# Pooler -SOCIAL_SUPABASE__POOLER_TENANT_ID=local -SOCIAL_SUPABASE__POOLER_DEFAULT_POOL_SIZE=20 -SOCIAL_SUPABASE__POOLER_MAX_CLIENT_CONN=100 -SOCIAL_SUPABASE__POOLER_DB_POOL_SIZE=5 - -####### -# Auth 可选项(默认允许邮箱注册) -SOCIAL_SUPABASE__ENABLE_EMAIL_SIGNUP=true -SOCIAL_SUPABASE__ENABLE_EMAIL_AUTOCONFIRM=false -SOCIAL_SUPABASE__ENABLE_ANONYMOUS_USERS=false -SOCIAL_SUPABASE__ENABLE_PHONE_SIGNUP=false -SOCIAL_SUPABASE__ENABLE_PHONE_AUTOCONFIRM=false -SOCIAL_SUPABASE__ADDITIONAL_REDIRECT_URLS= -SOCIAL_SUPABASE__DISABLE_SIGNUP=false - -####### -# SMTP(上云必配,本地可留空) -SOCIAL_SUPABASE__SMTP_ADMIN_EMAIL= -SOCIAL_SUPABASE__SMTP_HOST= -SOCIAL_SUPABASE__SMTP_PORT= -SOCIAL_SUPABASE__SMTP_USER= -SOCIAL_SUPABASE__SMTP_PASS= -SOCIAL_SUPABASE__SMTP_SENDER_NAME= - -####### -# Auth 邮件模板 URL(本地默认走 mail-templates 静态服务) -SOCIAL_SUPABASE__MAILER_TEMPLATES_CONFIRMATION=http://mail-templates/confirmation.html -SOCIAL_SUPABASE__MAILER_TEMPLATES_RECOVERY=http://mail-templates/recovery.html - -####### -# Auth 邮件主题(仅保留注册确认与重置密码) -SOCIAL_SUPABASE__MAILER_SUBJECTS_CONFIRMATION=请确认你的注册邮箱 -SOCIAL_SUPABASE__MAILER_SUBJECTS_RECOVERY=重置你的账户密码 -SOCIAL_SUPABASE__MAILER_OTP_LENGTH=6 -SOCIAL_SUPABASE__MAILER_OTP_EXP=300 - -####### -# Storage/Image 可选配置 -SOCIAL_SUPABASE__PGRST_DB_SCHEMAS=public -SOCIAL_SUPABASE__FUNCTIONS_VERIFY_JWT=false -SOCIAL_SUPABASE__IMGPROXY_ENABLE_WEBP_DETECTION=true -SOCIAL_SUPABASE__STORAGE_BUCKET_PUBLIC=public -SOCIAL_SUPABASE__STORAGE_BUCKET_PRIVATE=private +# Auth 可选项(云 Supabase Auth 行为控制) +# SOCIAL_SUPABASE__SITE_URL=https://your-app-domain.example +# SOCIAL_SUPABASE__ADDITIONAL_REDIRECT_URLS= ############ # Agent Chat 附件存储配置(仅基础设施变量) diff --git a/.github/workflows/manual-live-e2e.yml b/.github/workflows/manual-live-e2e.yml deleted file mode 100644 index e51b568..0000000 --- a/.github/workflows/manual-live-e2e.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Manual Live E2E - -on: - workflow_dispatch: - inputs: - run_live_suite: - description: "Run backend live e2e suite" - required: true - default: "true" - type: choice - options: - - "true" - - "false" - -jobs: - backend-live-e2e: - if: ${{ inputs.run_live_suite == 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 45 - env: - AGENT_LIVE_E2E: "1" - AGENT_LIVE_INTEGRATION: "1" - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Setup uv - uses: astral-sh/setup-uv@v3 - - - name: Restore .env from secret - shell: bash - run: | - if [ -z "${{ secrets.SOCIAL_APP_ENV_FILE }}" ]; then - echo "Missing required secret: SOCIAL_APP_ENV_FILE" - exit 1 - fi - printf '%s' "${{ secrets.SOCIAL_APP_ENV_FILE }}" > .env - - - name: Install dependencies - run: uv sync - - - name: Start local Supabase stack - run: docker compose --env-file .env -f infra/docker/docker-compose.yml up -d - - - name: Wait for Postgres - shell: bash - run: | - for i in $(seq 1 30); do - if nc -z 127.0.0.1 5434; then - exit 0 - fi - sleep 2 - done - echo "Postgres is not ready" - docker compose --env-file .env -f infra/docker/docker-compose.yml ps - exit 1 - - - name: Apply database migrations - run: uv run alembic -c backend/alembic/alembic.ini upgrade head - - - name: Run live E2E tests - run: uv run pytest backend/tests/e2e/test_agent_live_flow.py -m live -v -rs - - - name: Dump container logs on failure - if: failure() - run: docker compose --env-file .env -f infra/docker/docker-compose.yml logs --no-color - - - name: Shutdown local Supabase stack - if: always() - run: docker compose --env-file .env -f infra/docker/docker-compose.yml down -v diff --git a/.opencode/opencode.json b/.opencode/opencode.json index 69c4387..8df3a87 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -1,12 +1,19 @@ { "$schema": "https://opencode.ai/config.json", "mcp": { - "supabase_mcp": { - "type": "remote", - "url": "http://localhost:8001/mcp", - "oauth": false, + "supabase": { + "type": "local", "enabled": true, - "timeout": 10000 + "command": [ + "npx", + "-y", + "@aliyun-supabase/mcp-server-supabase@latest", + "--features=aliyun", + "--region-id=cn-shenzhen" + ], + "environment": { + "ALIYUN_ACCESS_TOKEN": "" + } } } } diff --git a/backend/src/core/auth/jwt_verifier.py b/backend/src/core/auth/jwt_verifier.py new file mode 100644 index 0000000..d232cf8 --- /dev/null +++ b/backend/src/core/auth/jwt_verifier.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from typing import Any, cast + +import jwt + + +class TokenValidationError(Exception): + pass + + +class TokenVerifierUnavailableError(Exception): + pass + + +class JwtVerifier: + def __init__(self, jwks_url: str, issuer: str, audience: str) -> None: + self._issuer: str = issuer + self._audience: str = audience + self._jwks_client: jwt.PyJWKClient = jwt.PyJWKClient(jwks_url) + + def verify(self, token: str) -> dict[str, Any]: + try: + key = self._jwks_client.get_signing_key_from_jwt(token) + except jwt.PyJWKClientConnectionError as exc: + raise TokenVerifierUnavailableError("Unable to fetch JWKS") from exc + except jwt.PyJWKClientError as exc: + raise TokenValidationError("Unable to resolve signing key") from exc + + try: + payload = jwt.decode( + token, + key.key, + algorithms=["RS256"], + audience=self._audience, + issuer=self._issuer, + options={"require": ["sub", "aud", "iss", "exp"]}, + ) + except ( + jwt.ExpiredSignatureError, + jwt.InvalidAudienceError, + jwt.InvalidIssuerError, + jwt.InvalidSignatureError, + jwt.DecodeError, + jwt.PyJWTError, + ) as exc: + raise TokenValidationError("Token validation failed") from exc + + if not isinstance(payload, dict): + raise TokenValidationError("Token payload must be a JSON object") + + return cast(dict[str, Any], payload) diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py index b044857..94976df 100644 --- a/backend/src/core/config/settings.py +++ b/backend/src/core/config/settings.py @@ -4,7 +4,14 @@ from pathlib import Path from typing import ClassVar, Literal from urllib.parse import quote -from pydantic import BaseModel, Field, computed_field, field_validator, model_validator +from pydantic import ( + AnyHttpUrl, + BaseModel, + Field, + computed_field, + field_validator, + model_validator, +) from pydantic_settings import BaseSettings, SettingsConfigDict @@ -116,14 +123,14 @@ class RedisSettings(BaseModel): class SupabaseSettings(BaseModel): - public_scheme: str = "http" - public_host: str = "localhost" - kong_http_port: int = 8000 - site_url: str = "http://localhost:3000" - additional_redirect_urls: list[str] = Field(default_factory=list) + public_url: AnyHttpUrl anon_key: str = "CHANGE_ME" service_role_key: str = "CHANGE_ME" - jwt_secret: str | None = None + jwt_audience: str = "authenticated" + jwt_issuer: str | None = None + jwks_url: str | None = None + site_url: str | None = None + additional_redirect_urls: list[str] = Field(default_factory=list) @field_validator("additional_redirect_urls", mode="before") @classmethod @@ -136,15 +143,24 @@ class SupabaseSettings(BaseModel): return [str(item).strip() for item in value if str(item).strip()] return [] - @computed_field - @property - def public_url(self) -> str: - return f"{self.public_scheme}://{self.public_host}:{self.kong_http_port}" + @model_validator(mode="after") + def compute_defaults(self) -> "SupabaseSettings": + base = str(self.public_url).rstrip("/") + if self.jwt_issuer is None: + self.jwt_issuer = f"{base}/auth/v1" + + if self.jwks_url is None: + self.jwks_url = f"{self.jwt_issuer}/.well-known/jwks.json" + + if self.site_url is None: + self.site_url = "http://localhost:3000" + + return self @computed_field @property def url(self) -> str: - return self.public_url + return str(self.public_url) class StorageSettings(BaseModel): @@ -205,7 +221,7 @@ class Settings(BaseSettings): runtime: RuntimeSettings = RuntimeSettings() cors: CorsSettings = CorsSettings() redis: RedisSettings = RedisSettings() - supabase: SupabaseSettings = SupabaseSettings() + supabase: SupabaseSettings = Field() storage: StorageSettings = StorageSettings() llm: LlmSettings = LlmSettings() agent_runtime: AgentRuntimeSettings = AgentRuntimeSettings() @@ -236,4 +252,4 @@ class Settings(BaseSettings): ) -config = Settings() +config = Settings() # type: ignore[reportCallIssue] diff --git a/backend/src/v1/users/dependencies.py b/backend/src/v1/users/dependencies.py index 6ea01d8..b17c44c 100644 --- a/backend/src/v1/users/dependencies.py +++ b/backend/src/v1/users/dependencies.py @@ -3,10 +3,14 @@ from __future__ import annotations from typing import Annotated from uuid import UUID -import jwt from fastapi import Depends, Header, HTTPException from sqlalchemy.ext.asyncio import AsyncSession +from core.auth.jwt_verifier import ( + JwtVerifier, + TokenValidationError, + TokenVerifierUnavailableError, +) from core.auth.models import CurrentUser from core.config.settings import config from core.db import get_db @@ -18,6 +22,7 @@ from v1.users.service import AuthLookupAdapter, UserService logger = get_logger("v1.users.dependencies") _auth_gateway: SupabaseAuthGateway | None = None +_jwt_verifier: JwtVerifier | None = None def get_auth_gateway() -> SupabaseAuthGateway: @@ -27,6 +32,19 @@ def get_auth_gateway() -> SupabaseAuthGateway: return _auth_gateway +def get_jwt_verifier() -> JwtVerifier: + global _jwt_verifier + if _jwt_verifier is None: + jwks_url = config.supabase.jwks_url + issuer = config.supabase.jwt_issuer + audience = config.supabase.jwt_audience + if not jwks_url or not issuer or not audience: + logger.error("JWT validation failed: verifier config not configured") + raise HTTPException(status_code=503, detail="JWT verifier not configured") + _jwt_verifier = JwtVerifier(jwks_url=jwks_url, issuer=issuer, audience=audience) + return _jwt_verifier + + def get_current_user(authorization: str | None = Header(default=None)) -> CurrentUser: if not authorization: logger.warning("JWT validation failed: missing authorization header") @@ -37,46 +55,17 @@ def get_current_user(authorization: str | None = Header(default=None)) -> Curren logger.warning("JWT validation failed: invalid authorization scheme") raise HTTPException(status_code=401, detail="Unauthorized") - secret = config.supabase.jwt_secret - if not secret: - logger.error("JWT validation failed: secret not configured") - raise HTTPException(status_code=503, detail="JWT secret not configured") - - supabase_url = config.supabase.public_url.rstrip("/") - expected_issuer = f"{supabase_url}/auth/v1" - try: - payload = jwt.decode( - token, - secret, - algorithms=["HS256"], - audience="authenticated", - issuer=expected_issuer, - options={ - "verify_aud": True, - "verify_iss": True, - "verify_exp": True, - "require": ["sub", "aud", "iss", "exp"], - }, - ) - except jwt.ExpiredSignatureError: - logger.warning("JWT validation failed: token expired") - raise HTTPException(status_code=401, detail="Unauthorized") - except jwt.InvalidAudienceError: - logger.warning("JWT validation failed: invalid audience") - raise HTTPException(status_code=401, detail="Unauthorized") - except jwt.InvalidIssuerError: - logger.warning("JWT validation failed: invalid issuer") - raise HTTPException(status_code=401, detail="Unauthorized") - except jwt.InvalidSignatureError: - logger.warning("JWT validation failed: invalid signature") - raise HTTPException(status_code=401, detail="Unauthorized") - except jwt.DecodeError: - logger.warning("JWT validation failed: malformed token") - raise HTTPException(status_code=401, detail="Unauthorized") - except jwt.PyJWTError as exc: + payload = get_jwt_verifier().verify(token) + except HTTPException: + raise + except TokenVerifierUnavailableError: + logger.error("JWT validation failed: verifier unavailable") + raise HTTPException(status_code=503, detail="JWT verifier unavailable") + except TokenValidationError as exc: logger.warning( - "JWT validation failed: unknown error", error_type=type(exc).__name__ + "JWT validation failed", + error_type=type(exc).__name__, ) raise HTTPException(status_code=401, detail="Unauthorized") from exc diff --git a/backend/tests/integration/v1/agent/test_sse_flow_live.py b/backend/tests/integration/v1/agent/test_sse_flow_live.py index 6e7d9ee..0c5a5f0 100644 --- a/backend/tests/integration/v1/agent/test_sse_flow_live.py +++ b/backend/tests/integration/v1/agent/test_sse_flow_live.py @@ -1,45 +1,43 @@ from __future__ import annotations import os -from datetime import datetime, timedelta, timezone from uuid import UUID, uuid4 import httpx -import jwt import pytest from sqlalchemy import select -from core.config import config from core.db.session import AsyncSessionLocal from models.agent_chat_message import AgentChatMessage from models.agent_chat_session import AgentChatSession -from models.profile import Profile BASE_URL = os.getenv("AGENT_LIVE_BASE_URL", "http://localhost:5775") -async def _owner_id() -> UUID: - async with AsyncSessionLocal() as session: - owner_id = (await session.execute(select(Profile.id).limit(1))).scalar_one_or_none() - if owner_id is None: - pytest.skip("profile owner not found") - return owner_id +async def _live_access_token(client: httpx.AsyncClient) -> str: + email = os.getenv("AGENT_LIVE_EMAIL") + password = os.getenv("AGENT_LIVE_PASSWORD") + if not email or not password: + pytest.fail( + "AGENT_LIVE_INTEGRATION=1 requires AGENT_LIVE_EMAIL and AGENT_LIVE_PASSWORD" + ) + response = await client.post( + f"{BASE_URL}/api/v1/auth/sessions", + json={"email": email, "password": password}, + ) + response_text = response.text.strip().replace("\n", " ") + truncated_text = response_text[:200] + if len(response_text) > 200: + truncated_text += "..." -def _jwt_for(user_id: UUID) -> str: - secret = config.supabase.jwt_secret - if not secret: - pytest.skip("JWT secret not configured") - issuer = f"{config.supabase.public_url.rstrip('/')}/auth/v1" - payload = { - "sub": str(user_id), - "role": "authenticated", - "aud": "authenticated", - "iss": issuer, - "iat": datetime.now(timezone.utc), - "exp": datetime.now(timezone.utc) + timedelta(minutes=30), - } - return jwt.encode(payload, secret, algorithm="HS256") + assert response.status_code == 200, ( + f"live login failed: status={response.status_code}, response={truncated_text!r}" + ) + + token = response.json().get("access_token") + assert isinstance(token, str) and token + return token @pytest.mark.asyncio @@ -48,11 +46,10 @@ async def test_agent_sse_closed_loop_live() -> None: if os.getenv("AGENT_LIVE_INTEGRATION") != "1": pytest.skip("set AGENT_LIVE_INTEGRATION=1 to run live integration test") - owner_id = await _owner_id() - token = _jwt_for(owner_id) - headers = {"Authorization": f"Bearer {token}"} - async with httpx.AsyncClient(timeout=30.0) as client: + token = await _live_access_token(client) + headers = {"Authorization": f"Bearer {token}"} + run_resp = await client.post( f"{BASE_URL}/api/v1/agent/runs", headers=headers, @@ -76,9 +73,13 @@ async def test_agent_sse_closed_loop_live() -> None: events_url = f"{BASE_URL}/api/v1/agent/runs/{thread_id}/events" event_names: list[str] = [] - async with client.stream("GET", events_url, headers=headers, timeout=20.0) as sse_resp: + async with client.stream( + "GET", events_url, headers=headers, timeout=20.0 + ) as sse_resp: assert sse_resp.status_code == 200 - assert sse_resp.headers.get("content-type", "").startswith("text/event-stream") + assert sse_resp.headers.get("content-type", "").startswith( + "text/event-stream" + ) async for line in sse_resp.aiter_lines(): if line.startswith("event:"): event_names.append(line.split(":", 1)[1].strip()) @@ -94,6 +95,8 @@ async def test_agent_sse_closed_loop_live() -> None: assert session_row.total_cost >= 0 rows = await session.execute( - select(AgentChatMessage).where(AgentChatMessage.session_id == UUID(thread_id)) + select(AgentChatMessage).where( + AgentChatMessage.session_id == UUID(thread_id) + ) ) assert len(list(rows.scalars().all())) >= 1 diff --git a/backend/tests/unit/core/auth/test_jwt_verifier.py b/backend/tests/unit/core/auth/test_jwt_verifier.py new file mode 100644 index 0000000..dffc2e9 --- /dev/null +++ b/backend/tests/unit/core/auth/test_jwt_verifier.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from typing import Any, cast +from uuid import uuid4 + +import jwt +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from core.auth.jwt_verifier import ( + JwtVerifier, + TokenValidationError, + TokenVerifierUnavailableError, +) + + +def _set_jwks_client(verifier: JwtVerifier, client: Any) -> None: + cast(Any, verifier)._jwks_client = client + + +def _build_rsa_key_pair() -> tuple[str, str]: + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + public_pem = ( + private_key.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode("utf-8") + ) + return private_pem, public_pem + + +def _build_token(*, private_key: str, sub: str, audience: str, issuer: str) -> str: + now = datetime.now(UTC) + payload = { + "sub": sub, + "aud": audience, + "iss": issuer, + "exp": now + timedelta(minutes=5), + } + return jwt.encode(payload, private_key, algorithm="RS256", headers={"kid": "kid-1"}) + + +def _build_expired_token( + *, private_key: str, sub: str, audience: str, issuer: str +) -> str: + now = datetime.now(UTC) + payload = { + "sub": sub, + "aud": audience, + "iss": issuer, + "exp": now - timedelta(minutes=1), + } + return jwt.encode(payload, private_key, algorithm="RS256", headers={"kid": "kid-1"}) + + +def _build_hs256_token(*, secret: str, sub: str, audience: str, issuer: str) -> str: + now = datetime.now(UTC) + payload = { + "sub": sub, + "aud": audience, + "iss": issuer, + "exp": now + timedelta(minutes=5), + } + return jwt.encode(payload, secret, algorithm="HS256", headers={"kid": "kid-1"}) + + +def test_verify_token_with_jwks_success() -> None: + user_id = uuid4() + audience = "authenticated" + issuer = "https://example.supabase.co/auth/v1" + private_key, public_key = _build_rsa_key_pair() + token = _build_token( + private_key=private_key, + sub=str(user_id), + audience=audience, + issuer=issuer, + ) + + verifier = JwtVerifier( + jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json", + issuer=issuer, + audience=audience, + ) + _set_jwks_client( + verifier, + SimpleNamespace( + get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key) + ), + ) + + claims = verifier.verify(token) + + assert claims["sub"] == str(user_id) + + +def test_verify_token_rejects_invalid_issuer() -> None: + audience = "authenticated" + issuer = "https://example.supabase.co/auth/v1" + private_key, public_key = _build_rsa_key_pair() + token_with_wrong_iss = _build_token( + private_key=private_key, + sub=str(uuid4()), + audience=audience, + issuer="https://wrong-issuer.example.com/auth/v1", + ) + + verifier = JwtVerifier( + jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json", + issuer=issuer, + audience=audience, + ) + _set_jwks_client( + verifier, + SimpleNamespace( + get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key) + ), + ) + + with pytest.raises(TokenValidationError): + verifier.verify(token_with_wrong_iss) + + +def test_verify_token_rejects_hs256_token() -> None: + audience = "authenticated" + issuer = "https://example.supabase.co/auth/v1" + _, public_key = _build_rsa_key_pair() + hs_token = _build_hs256_token( + secret="test-secret", + sub=str(uuid4()), + audience=audience, + issuer=issuer, + ) + + verifier = JwtVerifier( + jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json", + issuer=issuer, + audience=audience, + ) + _set_jwks_client( + verifier, + SimpleNamespace( + get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key) + ), + ) + + with pytest.raises(TokenValidationError): + verifier.verify(hs_token) + + +def test_verify_token_rejects_expired_token() -> None: + audience = "authenticated" + issuer = "https://example.supabase.co/auth/v1" + private_key, public_key = _build_rsa_key_pair() + expired_token = _build_expired_token( + private_key=private_key, + sub=str(uuid4()), + audience=audience, + issuer=issuer, + ) + + verifier = JwtVerifier( + jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json", + issuer=issuer, + audience=audience, + ) + _set_jwks_client( + verifier, + SimpleNamespace( + get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key) + ), + ) + + with pytest.raises(TokenValidationError): + verifier.verify(expired_token) + + +def test_verify_token_rejects_invalid_audience() -> None: + audience = "authenticated" + issuer = "https://example.supabase.co/auth/v1" + private_key, public_key = _build_rsa_key_pair() + wrong_aud_token = _build_token( + private_key=private_key, + sub=str(uuid4()), + audience="anon", + issuer=issuer, + ) + + verifier = JwtVerifier( + jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json", + issuer=issuer, + audience=audience, + ) + _set_jwks_client( + verifier, + SimpleNamespace( + get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key) + ), + ) + + with pytest.raises(TokenValidationError): + verifier.verify(wrong_aud_token) + + +def test_verify_token_rejects_invalid_signature() -> None: + audience = "authenticated" + issuer = "https://example.supabase.co/auth/v1" + private_key, public_key = _build_rsa_key_pair() + valid_token = _build_token( + private_key=private_key, + sub=str(uuid4()), + audience=audience, + issuer=issuer, + ) + tampered_token = f"{valid_token}x" + + verifier = JwtVerifier( + jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json", + issuer=issuer, + audience=audience, + ) + _set_jwks_client( + verifier, + SimpleNamespace( + get_signing_key_from_jwt=lambda _: SimpleNamespace(key=public_key) + ), + ) + + with pytest.raises(TokenValidationError): + verifier.verify(tampered_token) + + +def test_verify_token_maps_jwks_connection_error() -> None: + audience = "authenticated" + issuer = "https://example.supabase.co/auth/v1" + private_key, _ = _build_rsa_key_pair() + token = _build_token( + private_key=private_key, + sub=str(uuid4()), + audience=audience, + issuer=issuer, + ) + + verifier = JwtVerifier( + jwks_url="https://example.supabase.co/auth/v1/.well-known/jwks.json", + issuer=issuer, + audience=audience, + ) + + def _raise_connection_error(_: str) -> SimpleNamespace: + raise jwt.PyJWKClientConnectionError("network down") + + _set_jwks_client( + verifier, + SimpleNamespace(get_signing_key_from_jwt=_raise_connection_error), + ) + + with pytest.raises(TokenVerifierUnavailableError): + verifier.verify(token) diff --git a/backend/tests/unit/test_settings_supabase_env.py b/backend/tests/unit/test_settings_supabase_env.py index f5c620b..b4b4063 100644 --- a/backend/tests/unit/test_settings_supabase_env.py +++ b/backend/tests/unit/test_settings_supabase_env.py @@ -1,19 +1,18 @@ from __future__ import annotations +import pytest +from pydantic import ValidationError from pytest import MonkeyPatch -from core.config.settings import Settings +from core.config.settings import Settings, SupabaseSettings def test_social_prefixed_supabase_env_populates_settings( monkeypatch: MonkeyPatch, ) -> None: - monkeypatch.setenv("SOCIAL_SUPABASE__PUBLIC_SCHEME", "https") - monkeypatch.setenv("SOCIAL_SUPABASE__PUBLIC_HOST", "public.example") - monkeypatch.setenv("SOCIAL_SUPABASE__KONG_HTTP_PORT", "8443") + monkeypatch.setenv("SOCIAL_SUPABASE__PUBLIC_URL", "https://public.example:8443") monkeypatch.setenv("SOCIAL_SUPABASE__ANON_KEY", "anon-key") monkeypatch.setenv("SOCIAL_SUPABASE__SERVICE_ROLE_KEY", "service-key") - monkeypatch.setenv("SOCIAL_SUPABASE__JWT_SECRET", "jwt-secret") monkeypatch.setenv("SOCIAL_SUPABASE__SITE_URL", "https://app.example.com") monkeypatch.setenv( "SOCIAL_SUPABASE__ADDITIONAL_REDIRECT_URLS", @@ -27,10 +26,9 @@ def test_social_prefixed_supabase_env_populates_settings( settings = Settings() - assert settings.supabase.public_url == "https://public.example:8443" + assert str(settings.supabase.public_url) == "https://public.example:8443/" assert settings.supabase.anon_key == "anon-key" assert settings.supabase.service_role_key == "service-key" - assert settings.supabase.jwt_secret == "jwt-secret" assert settings.supabase.site_url == "https://app.example.com" assert settings.supabase.additional_redirect_urls == [ "https://a.example.com", @@ -38,9 +36,63 @@ def test_social_prefixed_supabase_env_populates_settings( ] supabase_settings = settings.model_dump()["supabase"] - assert supabase_settings["public_url"] == "https://public.example:8443" + assert str(supabase_settings["public_url"]) == "https://public.example:8443/" assert supabase_settings["anon_key"] == "anon-key" assert supabase_settings["service_role_key"] == "service-key" - assert supabase_settings["jwt_secret"] == "jwt-secret" assert supabase_settings["site_url"] == "https://app.example.com" + assert "jwt_secret" not in supabase_settings + assert "public_scheme" not in supabase_settings + assert "public_host" not in supabase_settings + assert "kong_http_port" not in supabase_settings assert settings.database_url == "postgresql+asyncpg://user:pass@db:5432/app" + + +def test_cloud_supabase_env_populates_settings(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv( + "SOCIAL_SUPABASE__PUBLIC_URL", "https://project.example.supabase.co" + ) + monkeypatch.setenv("SOCIAL_SUPABASE__ANON_KEY", "anon-key") + monkeypatch.setenv("SOCIAL_SUPABASE__SERVICE_ROLE_KEY", "service-key") + monkeypatch.setenv("SOCIAL_SUPABASE__JWT_AUDIENCE", "authenticated") + + settings = Settings() + + assert str(settings.supabase.public_url) == "https://project.example.supabase.co/" + assert settings.supabase.jwt_audience == "authenticated" + assert settings.supabase.jwt_issuer == "https://project.example.supabase.co/auth/v1" + assert ( + settings.supabase.jwks_url + == "https://project.example.supabase.co/auth/v1/.well-known/jwks.json" + ) + + supabase_settings = settings.model_dump()["supabase"] + assert "jwt_secret" not in supabase_settings + + +def test_missing_public_url_raises_validation_error() -> None: + with pytest.raises(ValidationError) as exc_info: + SupabaseSettings() + + assert "public_url" in str(exc_info.value) + + +def test_public_url_with_trailing_slash_normalizes_correctly( + monkeypatch: MonkeyPatch, +) -> None: + monkeypatch.setenv("SOCIAL_SUPABASE__PUBLIC_URL", "https://example.supabase.co/") + monkeypatch.setenv("SOCIAL_SUPABASE__ANON_KEY", "anon-key") + monkeypatch.setenv("SOCIAL_SUPABASE__SERVICE_ROLE_KEY", "service-key") + monkeypatch.setenv("SOCIAL_DATABASE__HOST", "db") + monkeypatch.setenv("SOCIAL_DATABASE__PORT", "5432") + monkeypatch.setenv("SOCIAL_DATABASE__NAME", "app") + monkeypatch.setenv("SOCIAL_DATABASE__USER", "user") + monkeypatch.setenv("SOCIAL_DATABASE__PASSWORD", "pass") + + settings = Settings() + + assert settings.supabase.jwt_issuer == "https://example.supabase.co/auth/v1" + assert ( + settings.supabase.jwks_url + == "https://example.supabase.co/auth/v1/.well-known/jwks.json" + ) + assert settings.supabase.url == "https://example.supabase.co/" diff --git a/docs/plans/2026-03-09-cloud-supabase-jwks-migration-plan.md b/docs/plans/2026-03-09-cloud-supabase-jwks-migration-plan.md new file mode 100644 index 0000000..61797dc --- /dev/null +++ b/docs/plans/2026-03-09-cloud-supabase-jwks-migration-plan.md @@ -0,0 +1,303 @@ +# Cloud Supabase Env Cleanup & JWKS Migration Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 切换到云 Supabase 后,移除本地自托管 Supabase 基础设施变量与编排,保留 Redis + DB + init-job,并将后端 JWT 验签从 `JWT_SECRET` 改为 JWKS 公钥验签。 + +**Architecture:** 后端配置收敛到“业务运行所需最小集合”(Supabase URL/anon/service role + DB + Redis)。认证链路采用 JWKS 拉取公钥并按 `kid` 验签,替代共享密钥 HS256。Docker 编排只保留业务依赖(redis、db、init-job),不再编排本地 Supabase 全家桶。 + +**Tech Stack:** FastAPI, Pydantic Settings, PyJWT (PyJWKClient), Docker Compose, pytest + +--- + +### Task 1: 固化云模式配置契约(先测后改) + +**Files:** +- Modify: `backend/tests/unit/test_settings_supabase_env.py` +- Modify: `.env.example` + +**Step 1: 写失败测试,定义新 Supabase 配置契约** + +```python +def test_social_prefixed_supabase_env_populates_settings(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("SOCIAL_SUPABASE__PUBLIC_URL", "https://project.example.supabase.co") + monkeypatch.setenv("SOCIAL_SUPABASE__ANON_KEY", "anon-key") + monkeypatch.setenv("SOCIAL_SUPABASE__SERVICE_ROLE_KEY", "service-key") + monkeypatch.setenv("SOCIAL_SUPABASE__JWT_AUDIENCE", "authenticated") + + settings = Settings() + + assert settings.supabase.public_url == "https://project.example.supabase.co" + assert settings.supabase.jwt_issuer == "https://project.example.supabase.co/auth/v1" + assert settings.supabase.jwks_url.endswith("/auth/v1/.well-known/jwks.json") +``` + +**Step 2: 运行测试确认失败** + +Run: `uv run pytest backend/tests/unit/test_settings_supabase_env.py -v` +Expected: FAIL(`public_url/jwks_url` 字段不存在或断言失败) + +**Step 3: 最小改动让测试通过(仅 settings 相关,逻辑改动在后续任务)** + +更新 `.env.example` 为云模式最小变量草案(先占位,后续任务会补最终文案): +- `SOCIAL_SUPABASE__PUBLIC_URL=` +- `SOCIAL_SUPABASE__ANON_KEY=` +- `SOCIAL_SUPABASE__SERVICE_ROLE_KEY=` +- `SOCIAL_SUPABASE__JWT_AUDIENCE=authenticated` +- `SOCIAL_SUPABASE__JWT_ISSUER=`(可选,默认由 PUBLIC_URL 推导) +- `SOCIAL_SUPABASE__JWKS_URL=`(可选,默认由 PUBLIC_URL 推导) + +**Step 4: 运行测试确认通过** + +Run: `uv run pytest backend/tests/unit/test_settings_supabase_env.py -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add backend/tests/unit/test_settings_supabase_env.py .env.example +git commit -m "test: define cloud supabase settings contract" +``` + +### Task 2: 重构 SupabaseSettings(移除 JWT_SECRET 依赖) + +**Files:** +- Modify: `backend/src/core/config/settings.py` +- Modify: `backend/tests/unit/test_settings_supabase_env.py` + +**Step 1: 写失败测试,约束默认推导行为** + +```python +assert settings.supabase.jwt_issuer == "https://project.example.supabase.co/auth/v1" +assert settings.supabase.jwks_url == "https://project.example.supabase.co/auth/v1/.well-known/jwks.json" +assert "jwt_secret" not in settings.model_dump()["supabase"] +``` + +**Step 2: 运行测试确认失败** + +Run: `uv run pytest backend/tests/unit/test_settings_supabase_env.py -v` +Expected: FAIL + +**Step 3: 实现最小配置重构** + +在 `SupabaseSettings` 中改为: +- 必填:`public_url`, `anon_key`, `service_role_key` +- 可选:`site_url`, `additional_redirect_urls` +- 新增:`jwt_audience`(默认 `authenticated`)、`jwt_issuer`(默认 `${public_url}/auth/v1`)、`jwks_url`(默认 `${jwt_issuer}/.well-known/jwks.json`) +- 删除:`jwt_secret`, `public_scheme`, `public_host`, `kong_http_port`, `kong_https_port` + +**Step 4: 运行测试确认通过** + +Run: `uv run pytest backend/tests/unit/test_settings_supabase_env.py -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add backend/src/core/config/settings.py backend/tests/unit/test_settings_supabase_env.py +git commit -m "refactor: migrate supabase config to cloud jwks fields" +``` + +### Task 3: 引入 JWKS 验签组件并接入认证依赖 + +**Files:** +- Create: `backend/src/core/auth/jwt_verifier.py` +- Modify: `backend/src/v1/users/dependencies.py` +- Create: `backend/tests/unit/core/auth/test_jwt_verifier.py` + +**Step 1: 先写失败测试(JWT 验签核心行为)** + +```python +def test_verify_token_with_jwks_success(...): + claims = verifier.verify(token) + assert claims["sub"] == str(user_id) + +def test_verify_token_rejects_invalid_issuer(...): + with pytest.raises(TokenValidationError): + verifier.verify(token_with_wrong_iss) +``` + +**Step 2: 运行测试确认失败** + +Run: `uv run pytest backend/tests/unit/core/auth/test_jwt_verifier.py -v` +Expected: FAIL(模块/类不存在) + +**Step 3: 实现最小 JWKS 验签逻辑** + +```python +class JwtVerifier: + def __init__(self, jwks_url: str, issuer: str, audience: str) -> None: ... + + def verify(self, token: str) -> dict[str, Any]: + key = self._jwks_client.get_signing_key_from_jwt(token) + return jwt.decode( + token, + key.key, + algorithms=["RS256", "ES256"], + audience=self._audience, + issuer=self._issuer, + options={"require": ["sub", "aud", "iss", "exp"]}, + ) +``` + +在 `get_current_user` 中替换原 `jwt_secret + HS256` 验签,统一映射为现有 401/503 语义。 + +**Step 4: 运行测试确认通过** + +Run: `uv run pytest backend/tests/unit/core/auth/test_jwt_verifier.py -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add backend/src/core/auth/jwt_verifier.py backend/src/v1/users/dependencies.py backend/tests/unit/core/auth/test_jwt_verifier.py +git commit -m "feat: validate access tokens via supabase jwks" +``` + +### Task 4: 回归认证路径与 live 测试兼容 + +**Files:** +- Modify: `backend/tests/integration/v1/agent/test_sse_flow_live.py` +- Modify: `backend/tests/integration/test_auth_routes.py`(如需) + +**Step 1: 写失败测试/调整 live 测试生成 token 方式** + +将 live 测试从“本地签发 HS256 token”改为“通过真实登录拿 access token”或“无测试账号时 skip”。 + +```python +if not os.getenv("AGENT_LIVE_EMAIL") or not os.getenv("AGENT_LIVE_PASSWORD"): + pytest.skip("missing live supabase credentials") +``` + +**Step 2: 运行相关测试确认失败(或旧逻辑不适配)** + +Run: `uv run pytest backend/tests/integration/v1/agent/test_sse_flow_live.py -m live -v` +Expected: 在旧代码下不可用/依赖 jwt_secret + +**Step 3: 完成最小实现改造** + +- 移除 `config.supabase.jwt_secret` 的测试依赖。 +- 保持 `@pytest.mark.live` 行为不变,避免影响常规 CI。 + +**Step 4: 运行测试确认通过(或受控 skip)** + +Run: `uv run pytest backend/tests/integration/v1/agent/test_sse_flow_live.py -m live -v` +Expected: PASS 或可解释的 SKIP(凭证缺失) + +**Step 5: Commit** + +```bash +git add backend/tests/integration/v1/agent/test_sse_flow_live.py backend/tests/integration/test_auth_routes.py +git commit -m "test: align live auth flow with cloud supabase tokens" +``` + +### Task 5: 裁剪 Docker Compose(移除本地 Supabase,保留 Redis/DB/init-job) + +**Files:** +- Modify: `infra/docker/docker-compose.yml` + +**Step 1: 写失败验证(compose 结构断言)** + +添加一个轻量脚本化检查(可在本任务临时执行,不必入库): + +```bash +docker compose --env-file .env -f infra/docker/docker-compose.yml config +``` + +在改造前记录当前包含的 Supabase 服务(`studio/kong/auth/rest/...`)作为对照。 + +**Step 2: 执行检查确认当前状态(基线)** + +Run: `docker compose --env-file .env -f infra/docker/docker-compose.yml config` +Expected: 输出包含 Supabase 全家桶服务 + +**Step 3: 最小实现裁剪** + +- 删除服务:`studio/kong/mail-templates/auth/rest/realtime/storage/imgproxy/meta/functions/analytics/vector/supavisor` +- 保留服务:`redis`, `db`, `init-job` +- `init-job` 环境变量移除:`SOCIAL_SUPABASE__ANON_KEY`, `SOCIAL_SUPABASE__SERVICE_ROLE_KEY`, `SOCIAL_SUPABASE__JWT_SECRET` +- `db` 服务切换为业务最小化所需配置(仅数据库启动与健康检查必需) + +**Step 4: 运行 compose 校验** + +Run: `docker compose --env-file .env -f infra/docker/docker-compose.yml config` +Expected: PASS,且仅保留 redis/db/init-job + +**Step 5: Commit** + +```bash +git add infra/docker/docker-compose.yml +git commit -m "refactor: remove local supabase stack from compose" +``` + +### Task 6: 清理环境模板与运行文档 + +**Files:** +- Modify: `.env.example` +- Modify: `docs/runtime/runtime-runbook.md` +- Modify: `infra/scripts/dev-migrate.sh` + +**Step 1: 先写文档/模板检查点(人工可核验)** + +定义必须满足: +- `.env.example` 不再包含本地 Supabase 基础设施变量(logflare/pooler/studio/kong/jwt_secret 等) +- 保留并标注后端必需项:`PUBLIC_URL`, `ANON_KEY`, `SERVICE_ROLE_KEY` +- runbook 的健康检查改为 Redis/DB/Web,而非 Kong + +**Step 2: 运行基线检查(改造前)** + +Run: `uv run pytest backend/tests/unit/test_settings_supabase_env.py -v` +Expected: 作为环境模板改造后的回归基线 + +**Step 3: 最小实现文档更新** + +- `docs/runtime/runtime-runbook.md`:把“启动基础设施”描述改为 `redis + db`。 +- `infra/scripts/dev-migrate.sh`:将提示从“Requires Supabase services”改为“Requires db/redis services”。 +- `.env.example`:按云模式分组,明确前端/后端变量边界。 + +**Step 4: 运行检查确认通过** + +Run: `docker compose --env-file .env -f infra/docker/docker-compose.yml config` +Expected: PASS + +**Step 5: Commit** + +```bash +git add .env.example docs/runtime/runtime-runbook.md infra/scripts/dev-migrate.sh +git commit -m "docs: update runtime guide for cloud supabase mode" +``` + +### Task 7: 全量验证与发布前检查 + +**Files:** +- Modify: `docs/runtime/runtime-runbook.md`(记录验证命令与结果) + +**Step 1: 运行静态检查** + +Run: `uv run ruff check backend/src backend/tests` +Expected: PASS + +**Step 2: 运行类型检查** + +Run: `uv run basedpyright` +Expected: PASS + +**Step 3: 运行测试(按影响面)** + +Run: `uv run pytest backend/tests/unit/test_settings_supabase_env.py backend/tests/unit/core/auth/test_jwt_verifier.py -v` +Expected: PASS + +Run: `uv run pytest backend/tests/integration/test_users_routes.py backend/tests/integration/test_auth_routes.py -v` +Expected: PASS + +**Step 4: 运行运行时门禁验证** + +Run: `docker compose --env-file .env -f infra/docker/docker-compose.yml up -d redis db && docker compose --env-file .env -f infra/docker/docker-compose.yml run --rm --build init-job uv run python -m core.runtime.cli bootstrap` +Expected: PASS(迁移 + init-data 成功) + +**Step 5: Commit** + +```bash +git add docs/runtime/runtime-runbook.md +git commit -m "chore: record cloud supabase migration verification" +``` diff --git a/docs/runtime/runtime-runbook.md b/docs/runtime/runtime-runbook.md index 8a4f8bd..7aa08bb 100644 --- a/docs/runtime/runtime-runbook.md +++ b/docs/runtime/runtime-runbook.md @@ -28,10 +28,10 @@ ### Step 1: 启动基础设施 ```bash -docker compose --env-file .env -f infra/docker/docker-compose.yml up -d +docker compose --env-file .env -f infra/docker/docker-compose.yml up -d redis ``` -通过标准:`docker compose ... ps` 中 redis/supabase 相关容器为 `running`。 +通过标准:`docker compose ... ps` 中 `redis` 容器为 `running`/`healthy`。 ### Step 2: 执行迁移与初始化 @@ -114,8 +114,11 @@ set +a WEB_BASE_URL="http://127.0.0.1:${SOCIAL_WEB__PORT:-5775}" -# 基础健康 -curl -fsS http://127.0.0.1:${SOCIAL_SUPABASE__KONG_HTTP_PORT:-8000}/health +# 基础健康(redis/web;数据库使用云 Supabase Postgres) +docker compose --env-file .env -f infra/docker/docker-compose.yml exec -T redis \ + sh -lc 'if [ -n "${REDIS_PASSWORD:-}" ]; then redis-cli -a "${REDIS_PASSWORD}" ping; else redis-cli ping; fi' + +curl -fsS "${WEB_BASE_URL}/health" # compose 状态 docker compose --env-file .env -f infra/docker/docker-compose.yml ps @@ -126,7 +129,7 @@ curl -sS -X POST "${WEB_BASE_URL}/api/v1/auth/sessions" \ -d '{"email":"demo@example.com","password":"secret123"}' ``` -通过标准:health 返回 2xx,关键容器 `running`,核心接口返回预期业务状态码。 +通过标准:redis 健康检查成功,web `/health` 返回 2xx,容器 `running`,核心接口返回预期业务状态码。 ### L2 可选(Auth/Profile 业务回归) @@ -193,31 +196,25 @@ docker compose --env-file .env -f infra/docker/docker-compose.yml exec -T redis - 定位:核对 `.env` 中 Supabase JWT 配置与签发方设置。 - 修复:修正配置后重启 web 进程并执行 L1/L2 验证。 -### 4) Auth 邮件模板未生效 / 注册返回超时但邮件已发送 +### 4) 基础设施容器异常(db/redis) -- 症状: - - 收到默认英文模板(非 `infra/mail-templates`)。 - - `signup/start` 偶发 500 或超时,但邮箱仍收到验证码邮件。 -- 根因:容器配置漂移(旧容器未按最新 compose/.env 重建),导致: - - `supabase-auth` 缺少 `GOTRUE_MAILER_TEMPLATES_*` 环境变量。 - - `supabase-mail-templates` 仍挂载旧路径。 +- 症状:web 启动失败、迁移失败、任务队列连接报错。 - 定位: ```bash -docker inspect supabase-auth --format '{{ range .Config.Env }}{{ println . }}{{ end }}' | grep GOTRUE_MAILER_TEMPLATES -docker inspect supabase-mail-templates --format '{{ range .Mounts }}{{ .Source }} -> {{ .Destination }}{{ println }}{{ end }}' +docker compose --env-file .env -f infra/docker/docker-compose.yml ps +docker compose --env-file .env -f infra/docker/docker-compose.yml logs db --tail=100 +docker compose --env-file .env -f infra/docker/docker-compose.yml logs redis --tail=100 ``` -- 修复:强制重建 auth 和 mail-templates(不改其他服务): +- 修复:按依赖顺序重建基础设施后重新 bootstrap。 ```bash -docker compose --env-file .env -f infra/docker/docker-compose.yml up -d --force-recreate --no-deps mail-templates auth +docker compose --env-file .env -f infra/docker/docker-compose.yml up -d --force-recreate redis +bash infra/scripts/dev-migrate.sh bootstrap ``` -- 复核标准: - - `docker inspect supabase-auth` 能看到 `GOTRUE_MAILER_TEMPLATES_CONFIRMATION/RECOVERY`。 - - `supabase-mail-templates` 挂载源为 `infra/mail-templates`。 - - `POST /api/v1/auth/verifications` 返回 `202` 且耗时恢复正常。 +- 复核标准:`redis` 健康检查通过,L1 核心接口 smoke 无 5xx。 --- @@ -248,7 +245,7 @@ docker compose --env-file .env -f infra/docker/docker-compose.yml up -d --force- |------|------| | 2026-02-24 | 创建运行时手册,删除 legacy 脚本,统一使用 gunicorn | | 2026-02-24 | 清理配置:合并 AppSettings 到 WebSettings,删除 Worker 旧配置 (enabled_queues/queues),统一使用 SOCIAL_WEB__GUNICORN__* 命名 | -| 2026-02-24 | 开发阶段 compose 暂不编排 web/worker,仅保留 redis/supabase 与 init-job | +| 2026-02-24 | 开发阶段 compose 暂不编排 web/worker,仅保留 redis/db 与 init-job | | 2026-02-24 | 新增 dev-app-up 脚本:手动基础设施后,一键 bootstrap + tmux 拉起 web/worker | | 2026-02-25 | 补充迁移防遗漏规则:容器迁移命令统一追加 --build;开发调试优先使用本地 CLI 一次性迁移脚本 | | 2026-02-25 | Auth 注册切换为 OTP 三段式:signup/start、signup/verify、signup/resend;邮件模板改为纯验证码展示 | @@ -263,3 +260,4 @@ docker compose --env-file .env -f infra/docker/docker-compose.yml up -d --force- | 2026-03-02 | 修正 bootstrap 命令:init-job 需要使用 `uv run python -m core.runtime.cli bootstrap` | | 2026-03-05 | 新增 Agent Runtime run/resume/events 运维排障流程(Taskiq + Redis + Last-Event-ID) | | 2026-03-06 | Web 启动从 gunicorn 迁移为纯 uvicorn,移除 `SOCIAL_WEB__GUNICORN__*` 配置,统一使用 `SOCIAL_WEB__WORKERS` | +| 2026-03-09 | 清理本地 Supabase 依赖描述:基础设施启动与巡检统一为 redis/db/web | diff --git a/infra/docker/docker-compose.yml b/infra/docker/docker-compose.yml index de59993..9aaa9e3 100644 --- a/infra/docker/docker-compose.yml +++ b/infra/docker/docker-compose.yml @@ -19,400 +19,6 @@ services: timeout: 3s retries: 5 - studio: - container_name: supabase-studio - image: supabase/studio:2025.12.17-sha-43f4f7f - restart: unless-stopped - healthcheck: - test: - ["CMD", "node", "-e", "fetch('http://studio:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})"] - timeout: 10s - interval: 5s - retries: 3 - depends_on: - analytics: - condition: service_healthy - environment: - HOSTNAME: "::" - STUDIO_PG_META_URL: http://meta:8080 - POSTGRES_PORT: 5432 - POSTGRES_HOST: db - POSTGRES_DB: postgres - POSTGRES_PASSWORD: ${SOCIAL_DATABASE__PASSWORD} - PG_META_CRYPTO_KEY: ${SOCIAL_SUPABASE__PG_META_CRYPTO_KEY} - DEFAULT_ORGANIZATION_NAME: ${SOCIAL_SUPABASE__STUDIO_DEFAULT_ORGANIZATION:-Social App} - DEFAULT_PROJECT_NAME: ${SOCIAL_SUPABASE__STUDIO_DEFAULT_PROJECT:-local} - OPENAI_API_KEY: ${SOCIAL_SUPABASE__OPENAI_API_KEY:-} - SUPABASE_URL: http://kong:8000 - SUPABASE_PUBLIC_URL: ${SOCIAL_SUPABASE__PUBLIC_SCHEME}://${SOCIAL_SUPABASE__PUBLIC_HOST}:${SOCIAL_SUPABASE__KONG_HTTP_PORT} - SUPABASE_ANON_KEY: ${SOCIAL_SUPABASE__ANON_KEY} - SUPABASE_SERVICE_KEY: ${SOCIAL_SUPABASE__SERVICE_ROLE_KEY} - AUTH_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} - LOGFLARE_PUBLIC_ACCESS_TOKEN: ${SOCIAL_SUPABASE__LOGFLARE_PUBLIC_ACCESS_TOKEN} - LOGFLARE_PRIVATE_ACCESS_TOKEN: ${SOCIAL_SUPABASE__LOGFLARE_PRIVATE_ACCESS_TOKEN} - LOGFLARE_URL: http://analytics:4000 - NEXT_PUBLIC_ENABLE_LOGS: "true" - NEXT_ANALYTICS_BACKEND_PROVIDER: postgres - - kong: - container_name: supabase-kong - image: kong:2.8.1 - restart: unless-stopped - ports: - - "127.0.0.1:${SOCIAL_SUPABASE__KONG_HTTP_PORT:-8000}:8000/tcp" - - "127.0.0.1:${SOCIAL_SUPABASE__KONG_HTTPS_PORT:-8443}:8443/tcp" - volumes: - - ./volumes/api/kong.yml:/home/kong/temp.yml:ro,z - depends_on: - analytics: - condition: service_healthy - environment: - KONG_DATABASE: "off" - KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml - KONG_DNS_ORDER: LAST,A,CNAME - KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction - KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k - KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k - KONG_PROXY_LISTEN: 0.0.0.0:8000, 0.0.0.0:8443 ssl - SUPABASE_ANON_KEY: ${SOCIAL_SUPABASE__ANON_KEY} - SUPABASE_SERVICE_KEY: ${SOCIAL_SUPABASE__SERVICE_ROLE_KEY} - DASHBOARD_USERNAME: ${SOCIAL_SUPABASE__DASHBOARD_USERNAME} - DASHBOARD_PASSWORD: ${SOCIAL_SUPABASE__DASHBOARD_PASSWORD} - entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start' - - mail-templates: - container_name: supabase-mail-templates - image: nginx:1.27-alpine - restart: unless-stopped - volumes: - - ../mail-templates:/usr/share/nginx/html:ro - healthcheck: - test: ["CMD", "sh", "-c", "wget --no-verbose --tries=1 --spider http://localhost/confirmation.html && wget --no-verbose --tries=1 --spider http://localhost/recovery.html"] - timeout: 5s - interval: 10s - retries: 3 - - auth: - container_name: supabase-auth - image: supabase/gotrue:v2.184.0 - restart: unless-stopped - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9999/health"] - timeout: 5s - interval: 5s - retries: 3 - depends_on: - db: - condition: service_healthy - analytics: - condition: service_healthy - mail-templates: - condition: service_healthy - environment: - GOTRUE_API_HOST: 0.0.0.0 - GOTRUE_API_PORT: 9999 - API_EXTERNAL_URL: ${SOCIAL_SUPABASE__PUBLIC_SCHEME}://${SOCIAL_SUPABASE__PUBLIC_HOST}:${SOCIAL_SUPABASE__KONG_HTTP_PORT} - GOTRUE_DB_DRIVER: postgres - GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${SOCIAL_DATABASE__PASSWORD}@db:5432/postgres - GOTRUE_SITE_URL: ${SOCIAL_SUPABASE__SITE_URL} - GOTRUE_URI_ALLOW_LIST: ${SOCIAL_SUPABASE__ADDITIONAL_REDIRECT_URLS:-} - GOTRUE_DISABLE_SIGNUP: ${SOCIAL_SUPABASE__DISABLE_SIGNUP:-false} - GOTRUE_JWT_ADMIN_ROLES: service_role - GOTRUE_JWT_AUD: authenticated - GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated - GOTRUE_JWT_EXP: ${SOCIAL_SUPABASE__JWT_EXPIRY:-3600} - GOTRUE_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} - GOTRUE_EXTERNAL_EMAIL_ENABLED: ${SOCIAL_SUPABASE__ENABLE_EMAIL_SIGNUP:-true} - GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${SOCIAL_SUPABASE__ENABLE_ANONYMOUS_USERS:-false} - GOTRUE_MAILER_AUTOCONFIRM: ${SOCIAL_SUPABASE__ENABLE_EMAIL_AUTOCONFIRM:-false} - GOTRUE_SMTP_ADMIN_EMAIL: ${SOCIAL_SUPABASE__SMTP_ADMIN_EMAIL:-} - GOTRUE_SMTP_HOST: ${SOCIAL_SUPABASE__SMTP_HOST:-} - GOTRUE_SMTP_PORT: ${SOCIAL_SUPABASE__SMTP_PORT:-} - GOTRUE_SMTP_USER: ${SOCIAL_SUPABASE__SMTP_USER:-} - GOTRUE_SMTP_PASS: ${SOCIAL_SUPABASE__SMTP_PASS:-} - GOTRUE_SMTP_SENDER_NAME: ${SOCIAL_SUPABASE__SMTP_SENDER_NAME:-} - GOTRUE_MAILER_URLPATHS_INVITE: ${SOCIAL_SUPABASE__MAILER_URLPATHS_INVITE:-/auth/v1/verify} - GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${SOCIAL_SUPABASE__MAILER_URLPATHS_CONFIRMATION:-/auth/v1/verify} - GOTRUE_MAILER_URLPATHS_RECOVERY: ${SOCIAL_SUPABASE__MAILER_URLPATHS_RECOVERY:-/auth/v1/recover} - GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${SOCIAL_SUPABASE__MAILER_URLPATHS_EMAIL_CHANGE:-/auth/v1/verify} - GOTRUE_MAILER_TEMPLATES_CONFIRMATION: ${SOCIAL_SUPABASE__MAILER_TEMPLATES_CONFIRMATION:-} - GOTRUE_MAILER_TEMPLATES_RECOVERY: ${SOCIAL_SUPABASE__MAILER_TEMPLATES_RECOVERY:-} - GOTRUE_MAILER_SUBJECTS_CONFIRMATION: ${SOCIAL_SUPABASE__MAILER_SUBJECTS_CONFIRMATION:-} - GOTRUE_MAILER_SUBJECTS_RECOVERY: ${SOCIAL_SUPABASE__MAILER_SUBJECTS_RECOVERY:-} - GOTRUE_MAILER_OTP_LENGTH: ${SOCIAL_SUPABASE__MAILER_OTP_LENGTH:-6} - GOTRUE_MAILER_OTP_EXP: ${SOCIAL_SUPABASE__MAILER_OTP_EXP:-300} - GOTRUE_EXTERNAL_PHONE_ENABLED: ${SOCIAL_SUPABASE__ENABLE_PHONE_SIGNUP:-false} - GOTRUE_SMS_AUTOCONFIRM: ${SOCIAL_SUPABASE__ENABLE_PHONE_AUTOCONFIRM:-false} - - rest: - container_name: supabase-rest - image: postgrest/postgrest:v14.1 - restart: unless-stopped - depends_on: - db: - condition: service_healthy - analytics: - condition: service_healthy - environment: - PGRST_DB_URI: postgres://authenticator:${SOCIAL_DATABASE__PASSWORD}@db:5432/postgres - PGRST_DB_SCHEMAS: ${SOCIAL_SUPABASE__PGRST_DB_SCHEMAS:-public} - PGRST_DB_ANON_ROLE: anon - PGRST_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} - PGRST_DB_USE_LEGACY_GUCS: "false" - PGRST_APP_SETTINGS_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} - PGRST_APP_SETTINGS_JWT_EXP: ${SOCIAL_SUPABASE__JWT_EXPIRY:-3600} - command: ["postgrest"] - - realtime: - container_name: realtime-dev.supabase-realtime - image: supabase/realtime:v2.68.0 - restart: unless-stopped - depends_on: - db: - condition: service_healthy - analytics: - condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "curl -sSfL --head -o /dev/null -H \"Authorization: Bearer ${SOCIAL_SUPABASE__ANON_KEY}\" http://localhost:4000/api/tenants/realtime-dev/health"] - timeout: 5s - interval: 30s - retries: 3 - start_period: 10s - environment: - PORT: 4000 - DB_HOST: db - DB_PORT: 5432 - DB_USER: supabase_admin - DB_PASSWORD: ${SOCIAL_DATABASE__PASSWORD} - DB_NAME: postgres - DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' - DB_ENC_KEY: supabaserealtime - API_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} - SECRET_KEY_BASE: ${SOCIAL_SUPABASE__SECRET_KEY_BASE} - ERL_AFLAGS: -proto_dist inet_tcp - DNS_NODES: "''" - RLIMIT_NOFILE: "10000" - APP_NAME: realtime - SEED_SELF_HOST: "true" - RUN_JANITOR: "true" - - storage: - container_name: supabase-storage - image: supabase/storage-api:v1.33.0 - restart: unless-stopped - volumes: - - storage_data:/var/lib/storage - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://storage:5000/status"] - timeout: 5s - interval: 5s - retries: 3 - depends_on: - db: - condition: service_healthy - rest: - condition: service_started - imgproxy: - condition: service_started - environment: - ANON_KEY: ${SOCIAL_SUPABASE__ANON_KEY} - SERVICE_KEY: ${SOCIAL_SUPABASE__SERVICE_ROLE_KEY} - POSTGREST_URL: http://rest:3000 - PGRST_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} - DATABASE_URL: postgres://supabase_storage_admin:${SOCIAL_DATABASE__PASSWORD}@db:5432/postgres - REQUEST_ALLOW_X_FORWARDED_PATH: "true" - FILE_SIZE_LIMIT: 52428800 - STORAGE_BACKEND: file - FILE_STORAGE_BACKEND_PATH: /var/lib/storage - TENANT_ID: stub - REGION: stub - GLOBAL_S3_BUCKET: stub - ENABLE_IMAGE_TRANSFORMATION: "true" - IMGPROXY_URL: http://imgproxy:5001 - - imgproxy: - container_name: supabase-imgproxy - image: darthsim/imgproxy:v3.30.1 - restart: unless-stopped - volumes: - - ./volumes/storage:/var/lib/storage:z - healthcheck: - test: ["CMD", "imgproxy", "health"] - timeout: 5s - interval: 5s - retries: 3 - environment: - IMGPROXY_BIND: ":5001" - IMGPROXY_LOCAL_FILESYSTEM_ROOT: / - IMGPROXY_USE_ETAG: "true" - IMGPROXY_ENABLE_WEBP_DETECTION: ${SOCIAL_SUPABASE__IMGPROXY_ENABLE_WEBP_DETECTION:-true} - IMGPROXY_MAX_SRC_RESOLUTION: 16.8 - - meta: - container_name: supabase-meta - image: supabase/postgres-meta:v0.95.1 - restart: unless-stopped - depends_on: - db: - condition: service_healthy - analytics: - condition: service_healthy - environment: - PG_META_PORT: 8080 - PG_META_DB_HOST: db - PG_META_DB_PORT: 5432 - PG_META_DB_NAME: postgres - PG_META_DB_USER: supabase_admin - PG_META_DB_PASSWORD: ${SOCIAL_DATABASE__PASSWORD} - CRYPTO_KEY: ${SOCIAL_SUPABASE__PG_META_CRYPTO_KEY} - - functions: - container_name: supabase-edge-functions - image: supabase/edge-runtime:v1.69.28 - restart: unless-stopped - volumes: - - ./volumes/functions:/home/deno/functions:Z - depends_on: - analytics: - condition: service_healthy - environment: - JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} - SUPABASE_URL: http://kong:8000 - SUPABASE_ANON_KEY: ${SOCIAL_SUPABASE__ANON_KEY} - SUPABASE_SERVICE_ROLE_KEY: ${SOCIAL_SUPABASE__SERVICE_ROLE_KEY} - SUPABASE_DB_URL: postgresql://postgres:${SOCIAL_DATABASE__PASSWORD}@db:5432/postgres - VERIFY_JWT: "${SOCIAL_SUPABASE__FUNCTIONS_VERIFY_JWT:-false}" - command: ["start", "--main-service", "/home/deno/functions/main"] - - analytics: - container_name: supabase-analytics - image: supabase/logflare:1.27.0 - restart: unless-stopped - ports: - - "127.0.0.1:${SOCIAL_SUPABASE__ANALYTICS_PORT:-4000}:4000" - healthcheck: - test: ["CMD", "curl", "http://localhost:4000/health"] - timeout: 5s - interval: 5s - retries: 10 - depends_on: - db: - condition: service_healthy - environment: - LOGFLARE_NODE_HOST: 127.0.0.1 - DB_USERNAME: supabase_admin - DB_DATABASE: _supabase - DB_HOSTNAME: db - DB_PORT: 5432 - DB_PASSWORD: ${SOCIAL_DATABASE__PASSWORD} - DB_SCHEMA: _analytics - LOGFLARE_PUBLIC_ACCESS_TOKEN: ${SOCIAL_SUPABASE__LOGFLARE_PUBLIC_ACCESS_TOKEN} - LOGFLARE_PRIVATE_ACCESS_TOKEN: ${SOCIAL_SUPABASE__LOGFLARE_PRIVATE_ACCESS_TOKEN} - LOGFLARE_SINGLE_TENANT: true - LOGFLARE_SUPABASE_MODE: true - POSTGRES_BACKEND_URL: postgresql://supabase_admin:${SOCIAL_DATABASE__PASSWORD}@db:5432/_supabase - POSTGRES_BACKEND_SCHEMA: _analytics - LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true - - db: - container_name: supabase-db - image: supabase/postgres:15.8.1.085 - restart: unless-stopped - ports: - - "127.0.0.1:${SOCIAL_DATABASE__PORT:-5432}:5432" - volumes: - - ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z - - ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z - - ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z - - ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z - - ./volumes/db/data:/var/lib/postgresql/data:Z - - ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:Z - - ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z - - ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z - - db-config:/etc/postgresql-custom - healthcheck: - test: ["CMD", "pg_isready", "-U", "postgres", "-h", "localhost"] - interval: 5s - timeout: 5s - retries: 10 - depends_on: - vector: - condition: service_healthy - environment: - POSTGRES_HOST: /var/run/postgresql - PGPORT: 5432 - POSTGRES_PORT: 5432 - PGPASSWORD: ${SOCIAL_DATABASE__PASSWORD} - POSTGRES_PASSWORD: ${SOCIAL_DATABASE__PASSWORD} - PGDATABASE: postgres - POSTGRES_DB: postgres - JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} - JWT_EXP: ${SOCIAL_SUPABASE__JWT_EXPIRY:-3600} - command: - ["postgres", "-c", "config_file=/etc/postgresql/postgresql.conf", "-c", "log_min_messages=fatal"] - - vector: - container_name: supabase-vector - image: timberio/vector:0.28.1-alpine - restart: unless-stopped - volumes: - - ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro,z - - ${SOCIAL_SUPABASE__DOCKER_SOCKET_LOCATION:-/var/run/docker.sock}:/var/run/docker.sock:ro,z - healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://vector:9001/health"] - timeout: 5s - interval: 5s - retries: 3 - environment: - LOGFLARE_PUBLIC_ACCESS_TOKEN: ${SOCIAL_SUPABASE__LOGFLARE_PUBLIC_ACCESS_TOKEN} - command: ["--config", "/etc/vector/vector.yml"] - security_opt: - - "label=disable" - - supavisor: - container_name: supabase-pooler - image: supabase/supavisor:2.7.4 - restart: unless-stopped - ports: - - "127.0.0.1:5432:5432" - - "127.0.0.1:${SOCIAL_SUPABASE__POOLER_PROXY_PORT_TRANSACTION:-6543}:6543" - volumes: - - ./volumes/pooler/pooler.exs:/etc/pooler/pooler.exs:ro,z - healthcheck: - test: ["CMD", "curl", "-sSfL", "--head", "-o", "/dev/null", "http://127.0.0.1:4000/api/health"] - interval: 10s - timeout: 5s - retries: 5 - depends_on: - db: - condition: service_healthy - analytics: - condition: service_healthy - environment: - PORT: 4000 - POSTGRES_PORT: 5432 - POSTGRES_DB: postgres - POSTGRES_PASSWORD: ${SOCIAL_DATABASE__PASSWORD} - DATABASE_URL: ecto://supabase_admin:${SOCIAL_DATABASE__PASSWORD}@db:5432/_supabase - CLUSTER_POSTGRES: true - SECRET_KEY_BASE: ${SOCIAL_SUPABASE__SECRET_KEY_BASE} - VAULT_ENC_KEY: ${SOCIAL_SUPABASE__VAULT_ENC_KEY} - API_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} - METRICS_JWT_SECRET: ${SOCIAL_SUPABASE__JWT_SECRET} - REGION: local - ERL_AFLAGS: -proto_dist inet_tcp - POOLER_TENANT_ID: ${SOCIAL_SUPABASE__POOLER_TENANT_ID:-local} - POOLER_DEFAULT_POOL_SIZE: ${SOCIAL_SUPABASE__POOLER_DEFAULT_POOL_SIZE:-20} - POOLER_MAX_CLIENT_CONN: ${SOCIAL_SUPABASE__POOLER_MAX_CLIENT_CONN:-100} - POOLER_POOL_MODE: transaction - DB_POOL_SIZE: ${SOCIAL_SUPABASE__POOLER_DB_POOL_SIZE:-5} - command: - ["/bin/sh", "-c", "/app/bin/migrate && /app/bin/supavisor eval \"$$(cat /etc/pooler/pooler.exs)\" && /app/bin/server"] - - # 开发阶段暂时禁用业务镜像(web/worker)。 - # 如需恢复,请从 git 历史恢复以下服务定义:web, worker-critical, - # worker-default, worker-bulk。 - init-job: build: context: ../.. @@ -422,18 +28,17 @@ services: restart: "no" environment: - PYTHONPATH=/app/backend/src - - SOCIAL_DATABASE__HOST=db - - SOCIAL_DATABASE__PORT=5432 + - SOCIAL_DATABASE__HOST=${SOCIAL_DATABASE__HOST} + - SOCIAL_DATABASE__PORT=${SOCIAL_DATABASE__PORT} + - SOCIAL_DATABASE__NAME=${SOCIAL_DATABASE__NAME} + - SOCIAL_DATABASE__USER=${SOCIAL_DATABASE__USER} - SOCIAL_DATABASE__PASSWORD=${SOCIAL_DATABASE__PASSWORD} - - SOCIAL_REDIS__HOST=redis - - SOCIAL_REDIS__PORT=6379 + - SOCIAL_REDIS__HOST=${SOCIAL_REDIS__HOST} + - SOCIAL_REDIS__PORT=${SOCIAL_REDIS__PORT} - SOCIAL_REDIS__PASSWORD=${SOCIAL_REDIS__PASSWORD:-} - - SOCIAL_SUPABASE__ANON_KEY=${SOCIAL_SUPABASE__ANON_KEY} - - SOCIAL_SUPABASE__SERVICE_ROLE_KEY=${SOCIAL_SUPABASE__SERVICE_ROLE_KEY} - - SOCIAL_SUPABASE__JWT_SECRET=${SOCIAL_SUPABASE__JWT_SECRET} - SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-dev} depends_on: - db: + redis: condition: service_healthy working_dir: /app/backend command: uv run python -m core.runtime.cli bootstrap @@ -442,5 +47,3 @@ services: volumes: redis_data: - db-config: - storage_data: diff --git a/infra/docker/volumes/api/kong.yml b/infra/docker/volumes/api/kong.yml deleted file mode 100644 index d2ef6cf..0000000 --- a/infra/docker/volumes/api/kong.yml +++ /dev/null @@ -1,238 +0,0 @@ -_format_version: '2.1' -_transform: true -consumers: -- username: DASHBOARD -- username: anon -- username: service_role -keyauth_credentials: -- consumer: anon - key: $SUPABASE_ANON_KEY -- consumer: service_role - key: $SUPABASE_SERVICE_KEY -acls: -- consumer: anon - group: anon -- consumer: service_role - group: admin -basicauth_credentials: -- consumer: DASHBOARD - username: $DASHBOARD_USERNAME - password: $DASHBOARD_PASSWORD -services: -- name: auth-v1-open - url: http://auth:9999/verify - routes: - - name: auth-v1-open - strip_path: true - paths: - - /auth/v1/verify - plugins: - - name: cors -- name: auth-v1-open-callback - url: http://auth:9999/callback - routes: - - name: auth-v1-open-callback - strip_path: true - paths: - - /auth/v1/callback - plugins: - - name: cors -- name: auth-v1-open-authorize - url: http://auth:9999/authorize - routes: - - name: auth-v1-open-authorize - strip_path: true - paths: - - /auth/v1/authorize - plugins: - - name: cors -- name: auth-v1 - _comment: 'GoTrue: /auth/v1/* -> http://auth:9999/*' - url: http://auth:9999/ - routes: - - name: auth-v1-all - strip_path: true - paths: - - /auth/v1/ - plugins: - - name: cors - - name: key-auth - config: - hide_credentials: false - - name: acl - config: - hide_groups_header: true - allow: - - admin - - anon -- name: rest-v1 - _comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*' - url: http://rest:3000/ - routes: - - name: rest-v1-all - strip_path: true - paths: - - /rest/v1/ - plugins: - - name: cors - - name: key-auth - config: - hide_credentials: true - - name: acl - config: - hide_groups_header: true - allow: - - admin - - anon -- name: graphql-v1 - _comment: 'PostgREST: /graphql/v1/* -> http://rest:3000/rpc/graphql' - url: http://rest:3000/rpc/graphql - routes: - - name: graphql-v1-all - strip_path: true - paths: - - /graphql/v1 - plugins: - - name: cors - - name: key-auth - config: - hide_credentials: true - - name: request-transformer - config: - add: - headers: - - Content-Profile:graphql_public - - name: acl - config: - hide_groups_header: true - allow: - - admin - - anon -- name: realtime-v1-ws - _comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*' - url: http://realtime-dev.supabase-realtime:4000/socket - protocol: ws - routes: - - name: realtime-v1-ws - strip_path: true - paths: - - /realtime/v1/ - plugins: - - name: cors - - name: key-auth - config: - hide_credentials: false - - name: acl - config: - hide_groups_header: true - allow: - - admin - - anon -- name: realtime-v1-rest - _comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*' - url: http://realtime-dev.supabase-realtime:4000/api - protocol: http - routes: - - name: realtime-v1-rest - strip_path: true - paths: - - /realtime/v1/api - plugins: - - name: cors - - name: key-auth - config: - hide_credentials: false - - name: acl - config: - hide_groups_header: true - allow: - - admin - - anon -- name: storage-v1 - _comment: 'Storage: /storage/v1/* -> http://storage:5000/*' - url: http://storage:5000/ - routes: - - name: storage-v1-all - strip_path: true - paths: - - /storage/v1/ - plugins: - - name: cors -- name: functions-v1 - _comment: 'Edge Functions: /functions/v1/* -> http://functions:9000/*' - url: http://functions:9000/ - routes: - - name: functions-v1-all - strip_path: true - paths: - - /functions/v1/ - plugins: - - name: cors -- name: analytics-v1 - _comment: 'Analytics: /analytics/v1/* -> http://logflare:4000/*' - url: http://analytics:4000/ - routes: - - name: analytics-v1-all - strip_path: true - paths: - - /analytics/v1/ -- name: meta - _comment: 'pg-meta: /pg/* -> http://pg-meta:8080/*' - url: http://meta:8080/ - routes: - - name: meta-all - strip_path: true - paths: - - /pg/ - plugins: - - name: key-auth - config: - hide_credentials: false - - name: acl - config: - hide_groups_header: true - allow: - - admin -- name: mcp-blocker - _comment: 'Block direct access to /api/mcp' - url: http://studio:3000/api/mcp - routes: - - name: mcp-blocker-route - strip_path: true - paths: - - /api/mcp - plugins: - - name: request-termination - config: - status_code: 403 - message: "Access is forbidden." -- name: mcp - _comment: 'MCP: /mcp -> http://studio:3000/api/mcp (local access)' - url: http://studio:3000/api/mcp - routes: - - name: mcp - strip_path: true - paths: - - /mcp - plugins: - - name: cors - - name: ip-restriction - config: - allow: - - 127.0.0.1 - - ::1 - - 172.19.0.1 - deny: [] -- name: dashboard - _comment: 'Studio: /* -> http://studio:3000/*' - url: http://studio:3000/ - routes: - - name: dashboard-all - strip_path: true - paths: - - / - plugins: - - name: cors - - name: basic-auth - config: - hide_credentials: true diff --git a/infra/docker/volumes/db/_supabase.sql b/infra/docker/volumes/db/_supabase.sql deleted file mode 100644 index 8882968..0000000 --- a/infra/docker/volumes/db/_supabase.sql +++ /dev/null @@ -1,2 +0,0 @@ -\set pguser `echo "$POSTGRES_USER"` -CREATE DATABASE _supabase WITH OWNER :pguser; diff --git a/infra/docker/volumes/db/jwt.sql b/infra/docker/volumes/db/jwt.sql deleted file mode 100644 index 93a8041..0000000 --- a/infra/docker/volumes/db/jwt.sql +++ /dev/null @@ -1,4 +0,0 @@ -\set jwt_secret `echo "$JWT_SECRET"` -\set jwt_exp `echo "$JWT_EXP"` -ALTER DATABASE postgres SET "app.settings.jwt_secret" TO :'jwt_secret'; -ALTER DATABASE postgres SET "app.settings.jwt_exp" TO :'jwt_exp'; diff --git a/infra/docker/volumes/db/logs.sql b/infra/docker/volumes/db/logs.sql deleted file mode 100644 index 794b086..0000000 --- a/infra/docker/volumes/db/logs.sql +++ /dev/null @@ -1,5 +0,0 @@ -\set pguser `echo "$POSTGRES_USER"` -\c _supabase -create schema if not exists _analytics; -alter schema _analytics owner to :pguser; -\c postgres diff --git a/infra/docker/volumes/db/pooler.sql b/infra/docker/volumes/db/pooler.sql deleted file mode 100644 index 516d986..0000000 --- a/infra/docker/volumes/db/pooler.sql +++ /dev/null @@ -1,5 +0,0 @@ -\set pguser `echo "$POSTGRES_USER"` -\c _supabase -create schema if not exists _supavisor; -alter schema _supavisor owner to :pguser; -\c postgres diff --git a/infra/docker/volumes/db/realtime.sql b/infra/docker/volumes/db/realtime.sql deleted file mode 100644 index 231cded..0000000 --- a/infra/docker/volumes/db/realtime.sql +++ /dev/null @@ -1,3 +0,0 @@ -\set pguser `echo "$POSTGRES_USER"` -create schema if not exists _realtime; -alter schema _realtime owner to :pguser; diff --git a/infra/docker/volumes/db/roles.sql b/infra/docker/volumes/db/roles.sql deleted file mode 100644 index d0641fa..0000000 --- a/infra/docker/volumes/db/roles.sql +++ /dev/null @@ -1,7 +0,0 @@ --- NOTE: change to your own passwords for production environments -\set pgpass `echo "$POSTGRES_PASSWORD"` -ALTER USER authenticator WITH PASSWORD :'pgpass'; -ALTER USER pgbouncer WITH PASSWORD :'pgpass'; -ALTER USER supabase_auth_admin WITH PASSWORD :'pgpass'; -ALTER USER supabase_functions_admin WITH PASSWORD :'pgpass'; -ALTER USER supabase_storage_admin WITH PASSWORD :'pgpass'; diff --git a/infra/docker/volumes/db/webhooks.sql b/infra/docker/volumes/db/webhooks.sql deleted file mode 100644 index 3c9b93c..0000000 --- a/infra/docker/volumes/db/webhooks.sql +++ /dev/null @@ -1,191 +0,0 @@ -BEGIN; -CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions; -CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin; -GRANT USAGE ON SCHEMA supabase_functions TO postgres, anon, authenticated, service_role; -ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role; -ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO postgres, anon, authenticated, service_role; -ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO postgres, anon, authenticated, service_role; -CREATE TABLE supabase_functions.migrations ( - version text PRIMARY KEY, - inserted_at timestamptz NOT NULL DEFAULT NOW() -); -INSERT INTO supabase_functions.migrations (version) VALUES ('initial'); -CREATE TABLE supabase_functions.hooks ( - id bigserial PRIMARY KEY, - hook_table_id integer NOT NULL, - hook_name text NOT NULL, - created_at timestamptz NOT NULL DEFAULT NOW(), - request_id bigint -); -CREATE INDEX supabase_functions_hooks_request_id_idx ON supabase_functions.hooks USING btree (request_id); -CREATE INDEX supabase_functions_hooks_h_table_id_h_name_idx ON supabase_functions.hooks USING btree (hook_table_id, hook_name); -COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.'; -CREATE FUNCTION supabase_functions.http_request() -RETURNS trigger -LANGUAGE plpgsql -AS $function$ -DECLARE - request_id bigint; - payload jsonb; - url text := TG_ARGV[0]::text; - method text := TG_ARGV[1]::text; - headers jsonb DEFAULT '{}'::jsonb; - params jsonb DEFAULT '{}'::jsonb; - timeout_ms integer DEFAULT 1000; -BEGIN - IF url IS NULL OR url = 'null' THEN - RAISE EXCEPTION 'url argument is missing'; - END IF; - IF method IS NULL OR method = 'null' THEN - RAISE EXCEPTION 'method argument is missing'; - END IF; - IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN - headers = '{"Content-Type": "application/json"}'::jsonb; - ELSE - headers = TG_ARGV[2]::jsonb; - END IF; - IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN - params = '{}'::jsonb; - ELSE - params = TG_ARGV[3]::jsonb; - END IF; - IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN - timeout_ms = 1000; - ELSE - timeout_ms = TG_ARGV[4]::integer; - END IF; - CASE - WHEN method = 'GET' THEN - SELECT http_get INTO request_id FROM net.http_get( - url, - params, - headers, - timeout_ms - ); - WHEN method = 'POST' THEN - payload = jsonb_build_object( - 'old_record', OLD, - 'record', NEW, - 'type', TG_OP, - 'table', TG_TABLE_NAME, - 'schema', TG_TABLE_SCHEMA - ); - SELECT http_post INTO request_id FROM net.http_post( - url, - payload, - params, - headers, - timeout_ms - ); - ELSE - RAISE EXCEPTION 'method argument % is invalid', method; - END CASE; - INSERT INTO supabase_functions.hooks - (hook_table_id, hook_name, request_id) - VALUES - (TG_RELID, TG_NAME, request_id); - RETURN NEW; -END -$function$; -DO -$$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_roles - WHERE rolname = 'supabase_functions_admin' - ) - THEN - CREATE USER supabase_functions_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION; - END IF; -END -$$; -GRANT ALL PRIVILEGES ON SCHEMA supabase_functions TO supabase_functions_admin; -GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA supabase_functions TO supabase_functions_admin; -GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA supabase_functions TO supabase_functions_admin; -ALTER USER supabase_functions_admin SET search_path = "supabase_functions"; -ALTER table "supabase_functions".migrations OWNER TO supabase_functions_admin; -ALTER table "supabase_functions".hooks OWNER TO supabase_functions_admin; -ALTER function "supabase_functions".http_request() OWNER TO supabase_functions_admin; -GRANT supabase_functions_admin TO postgres; -DO -$$ -BEGIN - IF EXISTS ( - SELECT 1 - FROM pg_roles - WHERE rolname = 'supabase_pg_net_admin' - ) - THEN - REASSIGN OWNED BY supabase_pg_net_admin TO supabase_admin; - DROP OWNED BY supabase_pg_net_admin; - DROP ROLE supabase_pg_net_admin; - END IF; -END -$$; -DO -$$ -BEGIN - IF EXISTS ( - SELECT 1 - FROM pg_extension - WHERE extname = 'pg_net' - ) - THEN - GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; - ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; - ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; - ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; - ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; - REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; - REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; - GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; - GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; - END IF; -END -$$; -CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access() -RETURNS event_trigger -LANGUAGE plpgsql -AS $$ -BEGIN - IF EXISTS ( - SELECT 1 - FROM pg_event_trigger_ddl_commands() AS ev - JOIN pg_extension AS ext - ON ev.objid = ext.oid - WHERE ext.extname = 'pg_net' - ) - THEN - GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; - ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; - ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; - ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; - ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; - REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; - REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; - GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; - GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; - END IF; -END; -$$; -COMMENT ON FUNCTION extensions.grant_pg_net_access IS 'Grants access to pg_net'; -DO -$$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_event_trigger - WHERE evtname = 'issue_pg_net_access' - ) THEN - CREATE EVENT TRIGGER issue_pg_net_access ON ddl_command_end WHEN TAG IN ('CREATE EXTENSION') - EXECUTE PROCEDURE extensions.grant_pg_net_access(); - END IF; -END -$$; -INSERT INTO supabase_functions.migrations (version) VALUES ('20210809183423_update_grants'); -ALTER function supabase_functions.http_request() SECURITY DEFINER; -ALTER function supabase_functions.http_request() SET search_path = supabase_functions; -REVOKE ALL ON FUNCTION supabase_functions.http_request() FROM PUBLIC; -GRANT EXECUTE ON FUNCTION supabase_functions.http_request() TO postgres, anon, authenticated, service_role; -COMMIT; diff --git a/infra/docker/volumes/functions/main/index.ts b/infra/docker/volumes/functions/main/index.ts deleted file mode 100644 index 496b68e..0000000 --- a/infra/docker/volumes/functions/main/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -Deno.serve(() => new Response("Supabase Edge Functions ready", { - headers: { - "content-type": "text/plain", - }, -})) diff --git a/infra/docker/volumes/logs/vector.yml b/infra/docker/volumes/logs/vector.yml deleted file mode 100644 index da61014..0000000 --- a/infra/docker/volumes/logs/vector.yml +++ /dev/null @@ -1,237 +0,0 @@ -api: - enabled: true - address: 0.0.0.0:9001 -sources: - docker_host: - type: docker_logs - exclude_containers: - - supabase-vector -transforms: - project_logs: - type: remap - inputs: - - docker_host - source: |- - .project = "default" - .event_message = del(.message) - .appname = del(.container_name) - del(.container_created_at) - del(.container_id) - del(.source_type) - del(.stream) - del(.label) - del(.image) - del(.host) - del(.stream) - router: - type: route - inputs: - - project_logs - route: - kong: '.appname == "supabase-kong"' - auth: '.appname == "supabase-auth"' - rest: '.appname == "supabase-rest"' - realtime: '.appname == "realtime-dev.supabase-realtime"' - storage: '.appname == "supabase-storage"' - functions: '.appname == "supabase-edge-functions"' - db: '.appname == "supabase-db"' - kong_logs: - type: remap - inputs: - - router.kong - source: |- - req, err = parse_nginx_log(.event_message, "combined") - if err == null { - .timestamp = req.timestamp - .metadata.request.headers.referer = req.referer - .metadata.request.headers.user_agent = req.agent - .metadata.request.headers.cf_connecting_ip = req.client - .metadata.request.method = req.method - .metadata.request.path = req.path - .metadata.request.protocol = req.protocol - .metadata.response.status_code = req.status - } - if err != null { - abort - } - kong_err: - type: remap - inputs: - - router.kong - source: |- - .metadata.request.method = "GET" - .metadata.response.status_code = 200 - parsed, err = parse_nginx_log(.event_message, "error") - if err == null { - .timestamp = parsed.timestamp - .severity = parsed.severity - .metadata.request.host = parsed.host - .metadata.request.headers.cf_connecting_ip = parsed.client - url, err = split(parsed.request, " ") - if err == null { - .metadata.request.method = url[0] - .metadata.request.path = url[1] - .metadata.request.protocol = url[2] - } - } - if err != null { - abort - } - auth_logs: - type: remap - inputs: - - router.auth - source: |- - parsed, err = parse_json(.event_message) - if err == null { - .metadata.timestamp = parsed.time - .metadata = merge!(.metadata, parsed) - } - rest_logs: - type: remap - inputs: - - router.rest - source: |- - parsed, err = parse_regex(.event_message, r'^(?P