merge upstream/dev: add user_id to configs, provider icons, i18n

This commit is contained in:
CREDO23 2026-02-11 20:05:00 +02:00
commit 78127243e2
87 changed files with 10445 additions and 5029 deletions

View file

@ -70,10 +70,33 @@ COPY . .
COPY scripts/docker/entrypoint.sh /app/scripts/docker/entrypoint.sh
RUN dos2unix /app/scripts/docker/entrypoint.sh && chmod +x /app/scripts/docker/entrypoint.sh
# Shared temp directory for file uploads between API and Worker containers.
# Python's tempfile module uses TMPDIR, so uploaded files land here.
# Mount the SAME volume at /shared_tmp on both API and Worker in Coolify.
RUN mkdir -p /shared_tmp
ENV TMPDIR=/shared_tmp
# Prevent uvloop compatibility issues
ENV PYTHONPATH=/app
ENV UVICORN_LOOP=asyncio
# SERVICE_ROLE controls which process this container runs:
# api FastAPI backend only (runs migrations on startup)
# worker Celery worker only
# beat Celery beat scheduler only
# all All three (legacy / dev default)
ENV SERVICE_ROLE=all
# Celery worker tuning (only used when SERVICE_ROLE=worker or all)
ENV CELERY_MAX_WORKERS=10
ENV CELERY_MIN_WORKERS=2
ENV CELERY_MAX_TASKS_PER_CHILD=50
# CELERY_QUEUES: comma-separated queues to consume (empty = all queues)
# "surfsense" fast tasks only (file uploads, podcasts, etc.)
# "surfsense.connectors" slow connector indexing tasks only
# "" both queues (default, for single-worker setups)
ENV CELERY_QUEUES=""
# Run
EXPOSE 8000-8001
CMD ["/app/scripts/docker/entrypoint.sh"]

View file

@ -0,0 +1,143 @@
"""Add user_id to new_llm_configs and image_generation_configs
Revision ID: 98
Revises: 97
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "98"
down_revision: str | None = "97"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Add user_id column to new_llm_configs and image_generation_configs.
Backfills existing rows with the search space owner's user_id.
"""
# --- new_llm_configs ---
# 1. Add nullable column first
op.execute(
"""
ALTER TABLE new_llm_configs
ADD COLUMN IF NOT EXISTS user_id UUID;
"""
)
# 2. Backfill from search space owner
op.execute(
"""
UPDATE new_llm_configs nlc
SET user_id = ss.user_id
FROM searchspaces ss
WHERE nlc.search_space_id = ss.id
AND nlc.user_id IS NULL;
"""
)
# 3. Make NOT NULL
op.execute(
"""
ALTER TABLE new_llm_configs
ALTER COLUMN user_id SET NOT NULL;
"""
)
# 4. Add FK constraint
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'fk_new_llm_configs_user_id'
AND table_name = 'new_llm_configs'
) THEN
ALTER TABLE new_llm_configs
ADD CONSTRAINT fk_new_llm_configs_user_id
FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE CASCADE;
END IF;
END$$;
"""
)
# 5. Add index for user_id lookups
op.execute(
"""
CREATE INDEX IF NOT EXISTS ix_new_llm_configs_user_id
ON new_llm_configs (user_id);
"""
)
# --- image_generation_configs ---
# 1. Add nullable column first
op.execute(
"""
ALTER TABLE image_generation_configs
ADD COLUMN IF NOT EXISTS user_id UUID;
"""
)
# 2. Backfill from search space owner
op.execute(
"""
UPDATE image_generation_configs igc
SET user_id = ss.user_id
FROM searchspaces ss
WHERE igc.search_space_id = ss.id
AND igc.user_id IS NULL;
"""
)
# 3. Make NOT NULL
op.execute(
"""
ALTER TABLE image_generation_configs
ALTER COLUMN user_id SET NOT NULL;
"""
)
# 4. Add FK constraint
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'fk_image_generation_configs_user_id'
AND table_name = 'image_generation_configs'
) THEN
ALTER TABLE image_generation_configs
ADD CONSTRAINT fk_image_generation_configs_user_id
FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE CASCADE;
END IF;
END$$;
"""
)
# 5. Add index for user_id lookups
op.execute(
"""
CREATE INDEX IF NOT EXISTS ix_image_generation_configs_user_id
ON image_generation_configs (user_id);
"""
)
def downgrade() -> None:
"""Remove user_id from new_llm_configs and image_generation_configs."""
op.execute(
"""
ALTER TABLE new_llm_configs DROP COLUMN IF EXISTS user_id;
"""
)
op.execute(
"""
ALTER TABLE image_generation_configs DROP COLUMN IF EXISTS user_id;
"""
)

View file

@ -109,7 +109,7 @@ class AgentConfig:
use_default_system_instructions=True,
citations_enabled=True,
config_id=AUTO_MODE_ID,
config_name="Auto (Load Balanced)",
config_name="Auto (Fastest)",
is_auto_mode=True,
)

View file

@ -1,7 +1,17 @@
import logging
import time
from collections import defaultdict
from contextlib import asynccontextmanager
from threading import Lock
import redis
from fastapi import Depends, FastAPI, HTTPException, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from slowapi import Limiter
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from slowapi.util import get_remote_address
from sqlalchemy.ext.asyncio import AsyncSession
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
@ -17,6 +27,147 @@ from app.schemas import UserCreate, UserRead, UserUpdate
from app.tasks.surfsense_docs_indexer import seed_surfsense_docs
from app.users import SECRET, auth_backend, current_active_user, fastapi_users
rate_limit_logger = logging.getLogger("surfsense.rate_limit")
# ============================================================================
# Rate Limiting Configuration (SlowAPI + Redis)
# ============================================================================
# Uses the same Redis instance as Celery for zero additional infrastructure.
# Protects auth endpoints from brute force and user enumeration attacks.
# SlowAPI limiter — provides default rate limits (60/min) for ALL routes
# via the ASGI middleware. This is the general safety net.
limiter = Limiter(
key_func=get_remote_address,
storage_uri=config.REDIS_APP_URL,
default_limits=["60/minute"],
)
def _rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded):
"""Custom 429 handler that returns JSON matching our frontend error format."""
retry_after = exc.detail.split("per")[-1].strip() if exc.detail else "60"
return JSONResponse(
status_code=429,
content={"detail": "RATE_LIMIT_EXCEEDED"},
headers={"Retry-After": retry_after},
)
# ============================================================================
# Auth-Specific Rate Limits (Redis-backed with in-memory fallback)
# ============================================================================
# Stricter per-IP limits on auth endpoints to prevent:
# - Brute force password attacks
# - User enumeration via REGISTER_USER_ALREADY_EXISTS
# - Email spam via forgot-password
#
# Primary: Redis INCR+EXPIRE (shared across all workers).
# Fallback: In-memory sliding window (per-worker) when Redis is unavailable.
# Same Redis instance as SlowAPI / Celery.
_rate_limit_redis: redis.Redis | None = None
# In-memory fallback rate limiter (per-worker, used only when Redis is down)
_memory_rate_limits: dict[str, list[float]] = defaultdict(list)
_memory_lock = Lock()
def _get_rate_limit_redis() -> redis.Redis:
"""Get or create Redis client for auth rate limiting."""
global _rate_limit_redis
if _rate_limit_redis is None:
_rate_limit_redis = redis.from_url(config.REDIS_APP_URL, decode_responses=True)
return _rate_limit_redis
def _check_rate_limit_memory(
client_ip: str, max_requests: int, window_seconds: int, scope: str
):
"""
In-memory fallback rate limiter using a sliding window.
Used only when Redis is unavailable. Per-worker only (not shared),
so effective limit = max_requests x num_workers.
"""
key = f"{scope}:{client_ip}"
now = time.monotonic()
with _memory_lock:
# Evict timestamps outside the current window
_memory_rate_limits[key] = [
t for t in _memory_rate_limits[key] if now - t < window_seconds
]
if len(_memory_rate_limits[key]) >= max_requests:
rate_limit_logger.warning(
f"Rate limit exceeded (in-memory fallback) on {scope} for IP {client_ip} "
f"({len(_memory_rate_limits[key])}/{max_requests} in {window_seconds}s)"
)
raise HTTPException(
status_code=429,
detail="RATE_LIMIT_EXCEEDED",
)
_memory_rate_limits[key].append(now)
def _check_rate_limit(
request: Request, max_requests: int, window_seconds: int, scope: str
):
"""
Check per-IP rate limit using Redis. Raises 429 if exceeded.
Uses atomic INCR + EXPIRE to avoid race conditions.
Falls back to in-memory sliding window if Redis is unavailable.
"""
client_ip = get_remote_address(request)
key = f"surfsense:auth_rate_limit:{scope}:{client_ip}"
try:
r = _get_rate_limit_redis()
# Atomic: increment first, then set TTL if this is a new key
pipe = r.pipeline()
pipe.incr(key)
pipe.expire(key, window_seconds)
result = pipe.execute()
except (redis.exceptions.RedisError, OSError) as exc:
# Redis unavailable — fall back to in-memory rate limiting
rate_limit_logger.warning(
f"Redis unavailable for rate limiting ({scope}), "
f"falling back to in-memory limiter for {client_ip}: {exc}"
)
_check_rate_limit_memory(client_ip, max_requests, window_seconds, scope)
return
current_count = result[0] # INCR returns the new value
if current_count > max_requests:
rate_limit_logger.warning(
f"Rate limit exceeded on {scope} for IP {client_ip} "
f"({current_count}/{max_requests} in {window_seconds}s)"
)
raise HTTPException(
status_code=429,
detail="RATE_LIMIT_EXCEEDED",
)
def rate_limit_login(request: Request):
"""5 login attempts per minute per IP."""
_check_rate_limit(request, max_requests=5, window_seconds=60, scope="login")
def rate_limit_register(request: Request):
"""3 registration attempts per minute per IP."""
_check_rate_limit(request, max_requests=3, window_seconds=60, scope="register")
def rate_limit_password_reset(request: Request):
"""2 password reset attempts per minute per IP."""
_check_rate_limit(
request, max_requests=2, window_seconds=60, scope="password_reset"
)
@asynccontextmanager
async def lifespan(app: FastAPI):
@ -45,6 +196,16 @@ def registration_allowed():
app = FastAPI(lifespan=lifespan)
# Register rate limiter and custom 429 handler
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# Add SlowAPI middleware for automatic rate limiting
# Uses Starlette BaseHTTPMiddleware (not the raw ASGI variant) to avoid
# corrupting StreamingResponse — SlowAPIASGIMiddleware re-sends
# http.response.start on every body chunk, breaking SSE/streaming endpoints.
app.add_middleware(SlowAPIMiddleware)
# Add ProxyHeaders middleware FIRST to trust proxy headers (e.g., from Cloudflare)
# This ensures FastAPI uses HTTPS in redirects when behind a proxy
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
@ -90,18 +251,25 @@ app.add_middleware(
)
app.include_router(
fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"]
fastapi_users.get_auth_router(auth_backend),
prefix="/auth/jwt",
tags=["auth"],
dependencies=[Depends(rate_limit_login)],
)
app.include_router(
fastapi_users.get_register_router(UserRead, UserCreate),
prefix="/auth",
tags=["auth"],
dependencies=[Depends(registration_allowed)], # blocks registration when disabled
dependencies=[
Depends(rate_limit_register),
Depends(registration_allowed), # blocks registration when disabled
],
)
app.include_router(
fastapi_users.get_reset_password_router(),
prefix="/auth",
tags=["auth"],
dependencies=[Depends(rate_limit_password_reset)],
)
app.include_router(
fastapi_users.get_verify_router(UserRead),

View file

@ -86,6 +86,11 @@ celery_app = Celery(
],
)
# ── Queue names ──────────────────────────────────────────────
# Default queue : fast, user-facing tasks (file upload, podcast, reindex, …)
# Connectors queue: slow, long-running indexing tasks (Notion, Gmail, web crawl, …)
CONNECTORS_QUEUE = f"{CELERY_TASK_DEFAULT_QUEUE}.connectors"
# Celery configuration
celery_app.conf.update(
# Task settings
@ -114,6 +119,34 @@ celery_app.conf.update(
broker_connection_retry_on_startup=True,
# Beat scheduler settings
beat_max_loop_interval=60, # Check every minute
# ── Task routing ─────────────────────────────────────────
# Route slow connector/indexing tasks to a dedicated queue so they
# never block fast user-facing tasks (file uploads, podcasts, etc.)
task_routes={
# Connector indexing tasks → connectors queue
"index_slack_messages": {"queue": CONNECTORS_QUEUE},
"index_notion_pages": {"queue": CONNECTORS_QUEUE},
"index_github_repos": {"queue": CONNECTORS_QUEUE},
"index_linear_issues": {"queue": CONNECTORS_QUEUE},
"index_jira_issues": {"queue": CONNECTORS_QUEUE},
"index_confluence_pages": {"queue": CONNECTORS_QUEUE},
"index_clickup_tasks": {"queue": CONNECTORS_QUEUE},
"index_google_calendar_events": {"queue": CONNECTORS_QUEUE},
"index_airtable_records": {"queue": CONNECTORS_QUEUE},
"index_google_gmail_messages": {"queue": CONNECTORS_QUEUE},
"index_google_drive_files": {"queue": CONNECTORS_QUEUE},
"index_discord_messages": {"queue": CONNECTORS_QUEUE},
"index_teams_messages": {"queue": CONNECTORS_QUEUE},
"index_luma_events": {"queue": CONNECTORS_QUEUE},
"index_elasticsearch_documents": {"queue": CONNECTORS_QUEUE},
"index_crawled_urls": {"queue": CONNECTORS_QUEUE},
"index_bookstack_pages": {"queue": CONNECTORS_QUEUE},
"index_obsidian_vault": {"queue": CONNECTORS_QUEUE},
"index_composio_connector": {"queue": CONNECTORS_QUEUE},
"delete_connector_with_documents": {"queue": CONNECTORS_QUEUE},
# Everything else (document processing, podcasts, reindexing,
# schedule checker, cleanup) stays on the default fast queue.
},
)
# Configure Celery Beat schedule

View file

@ -1066,6 +1066,12 @@ class ImageGenerationConfig(BaseModel, TimestampMixin):
"SearchSpace", back_populates="image_generation_configs"
)
# User who created this config
user_id = Column(
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
)
user = relationship("User", back_populates="image_generation_configs")
class ImageGeneration(BaseModel, TimestampMixin):
"""
@ -1284,6 +1290,7 @@ class SearchSourceConnector(BaseModel, TimestampMixin):
user_id = Column(
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
)
user = relationship("User", back_populates="search_source_connectors")
# Documents created by this connector (for cleanup on connector deletion)
documents = relationship("Document", back_populates="connector")
@ -1340,6 +1347,12 @@ class NewLLMConfig(BaseModel, TimestampMixin):
)
search_space = relationship("SearchSpace", back_populates="new_llm_configs")
# User who created this config
user_id = Column(
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
)
user = relationship("User", back_populates="new_llm_configs")
class Log(BaseModel, TimestampMixin):
__tablename__ = "logs"
@ -1608,6 +1621,27 @@ if config.AUTH_TYPE == "GOOGLE":
passive_deletes=True,
)
# Connectors created by this user
search_source_connectors = relationship(
"SearchSourceConnector",
back_populates="user",
passive_deletes=True,
)
# LLM configs created by this user
new_llm_configs = relationship(
"NewLLMConfig",
back_populates="user",
passive_deletes=True,
)
# Image generation configs created by this user
image_generation_configs = relationship(
"ImageGenerationConfig",
back_populates="user",
passive_deletes=True,
)
# User memories for personalized AI responses
memories = relationship(
"UserMemory",
@ -1687,6 +1721,27 @@ else:
passive_deletes=True,
)
# Connectors created by this user
search_source_connectors = relationship(
"SearchSourceConnector",
back_populates="user",
passive_deletes=True,
)
# LLM configs created by this user
new_llm_configs = relationship(
"NewLLMConfig",
back_populates="user",
passive_deletes=True,
)
# Image generation configs created by this user
image_generation_configs = relationship(
"ImageGenerationConfig",
back_populates="user",
passive_deletes=True,
)
# User memories for personalized AI responses
memories = relationship(
"UserMemory",

View file

@ -69,7 +69,7 @@ def _get_global_image_gen_config(config_id: int) -> dict | None:
if config_id == IMAGE_GEN_AUTO_MODE_ID:
return {
"id": IMAGE_GEN_AUTO_MODE_ID,
"name": "Auto (Load Balanced)",
"name": "Auto (Fastest)",
"provider": "AUTO",
"model_name": "auto",
"is_auto_mode": True,
@ -215,7 +215,7 @@ async def get_global_image_gen_configs(
safe_configs.append(
{
"id": 0,
"name": "Auto (Load Balanced)",
"name": "Auto (Fastest)",
"description": "Automatically routes across available image generation providers.",
"provider": "AUTO",
"custom_provider": None,
@ -273,7 +273,7 @@ async def create_image_gen_config(
"You don't have permission to create image generation configs in this search space",
)
db_config = ImageGenerationConfig(**config_data.model_dump())
db_config = ImageGenerationConfig(**config_data.model_dump(), user_id=user.id)
session.add(db_config)
await session.commit()
await session.refresh(db_config)

View file

@ -64,7 +64,7 @@ async def get_global_new_llm_configs(
safe_configs.append(
{
"id": 0,
"name": "Auto (Load Balanced)",
"name": "Auto (Fastest)",
"description": "Automatically routes requests across available LLM providers for optimal performance and rate limit handling. Recommended for most users.",
"provider": "AUTO",
"custom_provider": None,
@ -149,8 +149,8 @@ async def create_new_llm_config(
detail=f"Invalid LLM configuration: {error_message}",
)
# Create the config
db_config = NewLLMConfig(**config_data.model_dump())
# Create the config with user association
db_config = NewLLMConfig(**config_data.model_dump(), user_id=user.id)
session.add(db_config)
await session.commit()
await session.refresh(db_config)

View file

@ -324,7 +324,7 @@ async def _get_llm_config_by_id(
if config_id == 0:
return {
"id": 0,
"name": "Auto (Load Balanced)",
"name": "Auto (Fastest)",
"description": "Automatically routes requests across available LLM providers for optimal performance and rate limit handling",
"provider": "AUTO",
"custom_provider": None,
@ -402,7 +402,7 @@ async def _get_image_gen_config_by_id(
if config_id == 0:
return {
"id": 0,
"name": "Auto (Load Balanced)",
"name": "Auto (Fastest)",
"description": "Automatically routes requests across available image generation providers",
"provider": "AUTO",
"model_name": "auto",

View file

@ -6,6 +6,7 @@ ImageGeneration: Schemas for the actual image generation requests/results.
GlobalImageGenConfigRead: Schema for admin-configured YAML configs.
"""
import uuid
from datetime import datetime
from typing import Any
@ -79,6 +80,7 @@ class ImageGenerationConfigRead(ImageGenerationConfigBase):
id: int
created_at: datetime
search_space_id: int
user_id: uuid.UUID
model_config = ConfigDict(from_attributes=True)
@ -97,6 +99,7 @@ class ImageGenerationConfigPublic(BaseModel):
litellm_params: dict[str, Any] | None = None
created_at: datetime
search_space_id: int
user_id: uuid.UUID
model_config = ConfigDict(from_attributes=True)

View file

@ -243,6 +243,7 @@ class PublicChatSnapshotDetail(BaseModel):
message_count: int
thread_id: int
thread_title: str
created_by_user_id: str | None = None
class PublicChatSnapshotsBySpaceResponse(BaseModel):

View file

@ -7,6 +7,7 @@ NewLLMConfig combines LLM model settings with prompt configuration:
- Citation toggle
"""
import uuid
from datetime import datetime
from typing import Any
@ -90,6 +91,7 @@ class NewLLMConfigRead(NewLLMConfigBase):
id: int
created_at: datetime
search_space_id: int
user_id: uuid.UUID
model_config = ConfigDict(from_attributes=True)
@ -118,6 +120,7 @@ class NewLLMConfigPublic(BaseModel):
created_at: datetime
search_space_id: int
user_id: uuid.UUID
model_config = ConfigDict(from_attributes=True)

View file

@ -41,7 +41,7 @@ def get_global_llm_config(llm_config_id: int) -> dict | None:
if llm_config_id == AUTO_MODE_ID:
return {
"id": AUTO_MODE_ID,
"name": "Auto (Load Balanced)",
"name": "Auto (Fastest)",
"description": "Automatically routes requests across available LLM providers for optimal performance and rate limit handling",
"provider": "AUTO",
"model_name": "auto",

View file

@ -439,6 +439,9 @@ async def list_snapshots_for_search_space(
"message_count": len(s.message_ids) if s.message_ids else 0,
"thread_id": s.thread_id,
"thread_title": thread_titles.get(s.thread_id, "Untitled"),
"created_by_user_id": str(s.created_by_user_id)
if s.created_by_user_id
else None,
}
for s in snapshots
]

View file

@ -47,6 +47,14 @@ if config.AUTH_TYPE == "GOOGLE":
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
"""
Custom user manager extending fastapi-users BaseUserManager.
Authentication returns a generic error for both non-existent accounts
and incorrect passwords to comply with OWASP WSTG-IDNT-04 and
prevent user enumeration attacks.
"""
reset_password_token_secret = SECRET
verification_token_secret = SECRET

View file

@ -62,6 +62,7 @@ dependencies = [
"unstructured[all-docs]>=0.18.31",
"unstructured-client>=0.42.3",
"langchain-unstructured>=1.0.1",
"slowapi>=0.1.9",
]
[dependency-groups]

View file

@ -1,58 +1,129 @@
#!/bin/bash
set -e
# Function to handle shutdown gracefully
# ─────────────────────────────────────────────────────────────
# SERVICE_ROLE controls which process(es) this container runs.
#
# api FastAPI backend only (runs migrations on startup)
# worker Celery worker only
# beat Celery beat scheduler only
# all All three in one container (legacy / dev default)
#
# Set SERVICE_ROLE as an environment variable in Coolify for
# each service deployment.
# ─────────────────────────────────────────────────────────────
SERVICE_ROLE="${SERVICE_ROLE:-all}"
echo "Starting SurfSense with SERVICE_ROLE=${SERVICE_ROLE}"
# ── Autoscale defaults (override via env) ────────────────────
# CELERY_MAX_WORKERS max concurrent worker processes
# CELERY_MIN_WORKERS min workers kept warm
# CELERY_QUEUES comma-separated queues to consume
# (empty = all queues for backward compat)
CELERY_MAX_WORKERS="${CELERY_MAX_WORKERS:-10}"
CELERY_MIN_WORKERS="${CELERY_MIN_WORKERS:-2}"
CELERY_MAX_TASKS_PER_CHILD="${CELERY_MAX_TASKS_PER_CHILD:-50}"
CELERY_QUEUES="${CELERY_QUEUES:-}"
# ── Graceful shutdown ────────────────────────────────────────
PIDS=()
cleanup() {
echo "Shutting down services..."
kill -TERM "$backend_pid" "$celery_worker_pid" "$celery_beat_pid" 2>/dev/null || true
wait "$backend_pid" "$celery_worker_pid" "$celery_beat_pid" 2>/dev/null || true
for pid in "${PIDS[@]}"; do
kill -TERM "$pid" 2>/dev/null || true
done
for pid in "${PIDS[@]}"; do
wait "$pid" 2>/dev/null || true
done
exit 0
}
trap cleanup SIGTERM SIGINT
# Run database migrations with safeguards
echo "Running database migrations..."
# Wait for database to be ready (max 30 seconds)
for i in {1..30}; do
if python -c "from app.db import engine; import asyncio; asyncio.run(engine.dispose())" 2>/dev/null; then
echo "Database is ready."
break
# ── Database migrations (only for api / all) ─────────────────
run_migrations() {
echo "Running database migrations..."
for i in {1..30}; do
if python -c "from app.db import engine; import asyncio; asyncio.run(engine.dispose())" 2>/dev/null; then
echo "Database is ready."
break
fi
echo "Waiting for database... ($i/30)"
sleep 1
done
if timeout 60 alembic upgrade head 2>&1; then
echo "Migrations completed successfully."
else
echo "WARNING: Migration failed or timed out. Continuing anyway..."
echo "You may need to run migrations manually: alembic upgrade head"
fi
echo "Waiting for database... ($i/30)"
sleep 1
done
}
# Run migrations with timeout (60 seconds max)
if timeout 60 alembic upgrade head 2>&1; then
echo "Migrations completed successfully."
else
echo "WARNING: Migration failed or timed out. Continuing anyway..."
echo "You may need to run migrations manually: alembic upgrade head"
fi
# ── Service starters ─────────────────────────────────────────
start_api() {
echo "Starting FastAPI Backend..."
python main.py &
PIDS+=($!)
echo " FastAPI PID=${PIDS[-1]}"
}
echo "Starting FastAPI Backend..."
python main.py &
backend_pid=$!
start_worker() {
QUEUE_ARGS=""
if [ -n "${CELERY_QUEUES}" ]; then
QUEUE_ARGS="--queues=${CELERY_QUEUES}"
fi
# Wait a bit for backend to initialize
sleep 5
echo "Starting Celery Worker (autoscale=${CELERY_MAX_WORKERS},${CELERY_MIN_WORKERS}, max-tasks-per-child=${CELERY_MAX_TASKS_PER_CHILD}, queues=${CELERY_QUEUES:-all})..."
celery -A app.celery_app worker \
--loglevel=info \
--autoscale="${CELERY_MAX_WORKERS},${CELERY_MIN_WORKERS}" \
--max-tasks-per-child="${CELERY_MAX_TASKS_PER_CHILD}" \
--prefetch-multiplier=1 \
-Ofair \
${QUEUE_ARGS} &
PIDS+=($!)
echo " Celery Worker PID=${PIDS[-1]}"
}
echo "Starting Celery Worker..."
celery -A app.celery_app worker --loglevel=info --autoscale=128,4 &
celery_worker_pid=$!
start_beat() {
echo "Starting Celery Beat..."
celery -A app.celery_app beat --loglevel=info &
PIDS+=($!)
echo " Celery Beat PID=${PIDS[-1]}"
}
# Wait a bit for worker to initialize
sleep 3
# ── Main: run based on role ──────────────────────────────────
case "${SERVICE_ROLE}" in
api)
run_migrations
start_api
;;
worker)
start_worker
;;
beat)
start_beat
;;
all)
run_migrations
start_api
sleep 5
start_worker
sleep 3
start_beat
;;
*)
echo "ERROR: Unknown SERVICE_ROLE '${SERVICE_ROLE}'. Use: api, worker, beat, or all"
exit 1
;;
esac
echo "Starting Celery Beat..."
celery -A app.celery_app beat --loglevel=info &
celery_beat_pid=$!
echo "All services started. PIDs: Backend=$backend_pid, Worker=$celery_worker_pid, Beat=$celery_beat_pid"
echo "All requested services started. PIDs: ${PIDS[*]}"
# Wait for any process to exit
wait -n
# If we get here, one process exited, so exit with its status
# If we get here, one process exited unexpectedly
exit $?

6441
surfsense_backend/uv.lock generated

File diff suppressed because it is too large Load diff