feat: configure Gitea deployment and environment-managed WorldQuant credentials
Deploy production / deploy (push) Has been cancelled

This commit is contained in:
yuxuanhui
2026-09-09 09:58:09 +08:00
parent 20645d6d17
commit b8429efa3d
18 changed files with 430 additions and 45 deletions
+10 -1
View File
@@ -1,12 +1,16 @@
"""Deployment configuration; secrets are required and never included in API responses."""
from pathlib import Path
from cryptography.fernet import Fernet
from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file="../.env", extra="ignore")
model_config = SettingsConfigDict(
env_file=Path(__file__).resolve().parents[2] / ".env", extra="ignore", hide_input_in_errors=True
)
database_url: str = "postgresql+asyncpg://wq:wq@localhost:5432/wq"
admin_username: str = "admin"
@@ -15,6 +19,8 @@ class Settings(BaseSettings):
public_origin: str = "http://localhost:8080"
cookie_secure: bool = False
session_hours: int = Field(default=24, ge=1, le=168)
wq_email: str = ""
wq_password: SecretStr = SecretStr("")
wq_base_url: str = "https://api.worldquantbrain.com"
request_timeout: float = 30
retry_attempts: int = Field(default=4, ge=1, le=8)
@@ -26,6 +32,9 @@ class Settings(BaseSettings):
@model_validator(mode="after")
def validate_secrets(self):
self.wq_email = self.wq_email.strip()
if bool(self.wq_email) != bool(self.wq_password.get_secret_value()):
raise ValueError("WQ_EMAIL and WQ_PASSWORD must be configured together")
Fernet(self.encryption_key.get_secret_value().encode())
if self.cookie_secure and not self.public_origin.startswith("https://"):
raise ValueError("COOKIE_SECURE requires an HTTPS PUBLIC_ORIGIN")
+7 -4
View File
@@ -53,7 +53,7 @@ from .schemas import (
from .security import bootstrap, cipher, issue_session, require_auth, token_hash, valid_password
def account_output(account, client):
def account_output(account, client, settings):
keys = (
"email",
"wq_user_id",
@@ -70,6 +70,7 @@ def account_output(account, client):
return {
**{k: getattr(account, k) for k in keys},
"configured": bool(account.password_encrypted),
"credentials_source": "environment" if settings.wq_email else "database",
"session": client.session_info(),
}
@@ -210,10 +211,12 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
@api.get("/account", response_model=AccountOutput, tags=["account"])
async def get_account():
async with sessions() as db:
return account_output(await db.get(Account, 1), runner.client)
return account_output(await db.get(Account, 1), runner.client, settings)
@api.put("/account/credentials", response_model=AccountOutput, tags=["account"])
async def credentials(body: CredentialsInput):
if settings.wq_email:
raise HTTPException(409, "WorldQuant 凭据由环境变量管理,请修改部署配置并重启服务")
async with sessions() as db:
account = await db.get(Account, 1)
if account.wq_user_id and account.email.casefold() != body.email.casefold():
@@ -224,7 +227,7 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
account.email = body.email
account.password_encrypted = cipher(settings).encrypt(body.password.encode()).decode()
await db.commit()
return account_output(account, runner.client)
return account_output(account, runner.client, settings)
@api.patch("/account/preferences", response_model=AccountOutput, tags=["account"])
async def preferences(body: PreferencesInput):
@@ -233,7 +236,7 @@ def create_app(settings=None, wq_client=None, ai_model_factory=None):
for key, value in body.model_dump().items():
setattr(account, key, value)
await db.commit()
return account_output(account, runner.client)
return account_output(account, runner.client, settings)
async def account_job(kind):
async with sessions() as db:
+1
View File
@@ -287,6 +287,7 @@ class PlatformSessionOutput(BaseModel):
class AccountOutput(BaseModel):
email: str | None
configured: bool
credentials_source: Literal["environment", "database"]
wq_user_id: str | None
profile: dict
connection_status: str
+13 -3
View File
@@ -26,7 +26,7 @@ def cipher(settings) -> Fernet:
async def bootstrap(db, settings):
"""Only initialize missing singleton records; deployments never reset existing passwords."""
"""Initialize singletons and apply environment credentials without resetting the admin password."""
if not await db.get(Admin, 1):
db.add(
Admin(
@@ -35,8 +35,18 @@ async def bootstrap(db, settings):
password_hash=password_hasher.hash(settings.admin_password.get_secret_value()),
)
)
if not await db.get(Account, 1):
db.add(Account(id=1))
account = await db.get(Account, 1)
if account is None:
account = Account(id=1)
db.add(account)
if settings.wq_email:
# The environment must not bypass the single-account data ownership boundary.
if account.wq_user_id and (account.email or "").casefold() != settings.wq_email.casefold():
raise ValueError("WQ_EMAIL conflicts with the bound WorldQuant account")
account.email = settings.wq_email
account.password_encrypted = cipher(settings).encrypt(
settings.wq_password.get_secret_value().encode()
).decode()
await db.execute(delete(LoginSession).where(LoginSession.expires_at < now()))
await db.commit()