Skip to content

Commit 93b6df9

Browse files
Prisma Migrate - support setting custom migration dir (#10336)
* build(litellm-proxy-extras/utils.py): correctly generate baseline migration for non-empty db * fix(litellm-proxy-extras/utils.py): Fix issue in migration, where if a migration fails during baselining, all are still marked as applied * fix(prisma_client.py): don't pass separate schema.prisma to litellm-proxy-extras use the one in litellm-proxy-extras * fix(litellm-proxy-extras/utils.py): support passing custom dir for baselining db in read-only fs Fixes #9885 * fix(utils.py): give helpful warning message when permission denied error raised in fs
1 parent f2899cb commit 93b6df9

File tree

5 files changed

+158
-21
lines changed

5 files changed

+158
-21
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,4 @@ litellm/proxy/migrations/*config.yaml
8888
litellm/proxy/migrations/*
8989
config.yaml
9090
tests/litellm/litellm_core_utils/llm_cost_calc/log.txt
91+
tests/test_custom_dir/*

docs/my-website/docs/proxy/config_settings.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,7 @@ router_settings:
442442
| LITELLM_EMAIL | Email associated with LiteLLM account
443443
| LITELLM_GLOBAL_MAX_PARALLEL_REQUEST_RETRIES | Maximum retries for parallel requests in LiteLLM
444444
| LITELLM_GLOBAL_MAX_PARALLEL_REQUEST_RETRY_TIMEOUT | Timeout for retries of parallel requests in LiteLLM
445+
| LITELLM_MIGRATION_DIR | Custom migrations directory for prisma migrations, used for baselining db in read-only file systems.
445446
| LITELLM_HOSTED_UI | URL of the hosted UI for LiteLLM
446447
| LITELLM_LICENSE | License key for LiteLLM usage
447448
| LITELLM_LOCAL_MODEL_COST_MAP | Local configuration for model cost mapping in LiteLLM

litellm-proxy-extras/litellm_proxy_extras/utils.py

Lines changed: 123 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
import os
33
import random
44
import re
5+
import shutil
56
import subprocess
67
import time
8+
from datetime import datetime
79
from pathlib import Path
810
from typing import Optional
911

@@ -19,9 +21,30 @@ def str_to_bool(value: Optional[str]) -> bool:
1921
class ProxyExtrasDBManager:
2022
@staticmethod
2123
def _get_prisma_dir() -> str:
22-
"""Get the path to the migrations directory"""
23-
migrations_dir = os.path.dirname(__file__)
24-
return migrations_dir
24+
"""
25+
Get the path to the migrations directory
26+
27+
Set os.environ["LITELLM_MIGRATION_DIR"] to a custom migrations directory, to support baselining db in read-only fs.
28+
"""
29+
custom_migrations_dir = os.getenv("LITELLM_MIGRATION_DIR")
30+
pkg_migrations_dir = os.path.dirname(__file__)
31+
if custom_migrations_dir:
32+
# If migrations_dir exists, copy contents
33+
if os.path.exists(custom_migrations_dir):
34+
# Copy contents instead of directory itself
35+
for item in os.listdir(pkg_migrations_dir):
36+
src_path = os.path.join(pkg_migrations_dir, item)
37+
dst_path = os.path.join(custom_migrations_dir, item)
38+
if os.path.isdir(src_path):
39+
shutil.copytree(src_path, dst_path, dirs_exist_ok=True)
40+
else:
41+
shutil.copy2(src_path, dst_path)
42+
else:
43+
# If directory doesn't exist, create it and copy everything
44+
shutil.copytree(pkg_migrations_dir, custom_migrations_dir)
45+
return custom_migrations_dir
46+
47+
return pkg_migrations_dir
2548

2649
@staticmethod
2750
def _create_baseline_migration(schema_path: str) -> bool:
@@ -33,27 +56,29 @@ def _create_baseline_migration(schema_path: str) -> bool:
3356
# Create migrations/0_init directory
3457
init_dir.mkdir(parents=True, exist_ok=True)
3558

36-
# Generate migration SQL file
37-
migration_file = init_dir / "migration.sql"
59+
database_url = os.getenv("DATABASE_URL")
3860

3961
try:
40-
# Generate migration diff with increased timeout
62+
# 1. Generate migration SQL file by comparing empty state to current db state
63+
logger.info("Generating baseline migration...")
64+
migration_file = init_dir / "migration.sql"
4165
subprocess.run(
4266
[
4367
"prisma",
4468
"migrate",
4569
"diff",
4670
"--from-empty",
47-
"--to-schema-datamodel",
48-
str(schema_path),
71+
"--to-url",
72+
database_url,
4973
"--script",
5074
],
5175
stdout=open(migration_file, "w"),
5276
check=True,
5377
timeout=30,
54-
) # 30 second timeout
78+
)
5579

56-
# Mark migration as applied with increased timeout
80+
# 3. Mark the migration as applied since it represents current state
81+
logger.info("Marking baseline migration as applied...")
5782
subprocess.run(
5883
[
5984
"prisma",
@@ -73,8 +98,10 @@ def _create_baseline_migration(schema_path: str) -> bool:
7398
)
7499
return False
75100
except subprocess.CalledProcessError as e:
76-
logger.warning(f"Error creating baseline migration: {e}")
77-
return False
101+
logger.warning(
102+
f"Error creating baseline migration: {e}, {e.stderr}, {e.stdout}"
103+
)
104+
raise e
78105

79106
@staticmethod
80107
def _get_migration_names(migrations_dir: str) -> list:
@@ -104,8 +131,85 @@ def _resolve_specific_migration(migration_name: str):
104131
)
105132

106133
@staticmethod
107-
def _resolve_all_migrations(migrations_dir: str):
108-
"""Mark all existing migrations as applied"""
134+
def _resolve_all_migrations(migrations_dir: str, schema_path: str):
135+
"""
136+
1. Compare the current database state to schema.prisma and generate a migration for the diff.
137+
2. Run prisma migrate deploy to apply any pending migrations.
138+
3. Mark all existing migrations as applied.
139+
"""
140+
database_url = os.getenv("DATABASE_URL")
141+
diff_dir = (
142+
Path(migrations_dir)
143+
/ "migrations"
144+
/ f"{datetime.now().strftime('%Y%m%d%H%M%S')}_baseline_diff"
145+
)
146+
try:
147+
diff_dir.mkdir(parents=True, exist_ok=True)
148+
except Exception as e:
149+
if "Permission denied" in str(e):
150+
logger.warning(
151+
f"Permission denied - {e}\nunable to baseline db. Set LITELLM_MIGRATION_DIR environment variable to a writable directory to enable migrations."
152+
)
153+
return
154+
raise e
155+
diff_sql_path = diff_dir / "migration.sql"
156+
157+
# 1. Generate migration SQL for the diff between DB and schema
158+
try:
159+
logger.info("Generating migration diff between DB and schema.prisma...")
160+
with open(diff_sql_path, "w") as f:
161+
subprocess.run(
162+
[
163+
"prisma",
164+
"migrate",
165+
"diff",
166+
"--from-url",
167+
database_url,
168+
"--to-schema-datamodel",
169+
schema_path,
170+
"--script",
171+
],
172+
check=True,
173+
timeout=60,
174+
stdout=f,
175+
)
176+
except subprocess.CalledProcessError as e:
177+
logger.warning(f"Failed to generate migration diff: {e.stderr}")
178+
except subprocess.TimeoutExpired:
179+
logger.warning("Migration diff generation timed out.")
180+
181+
# check if the migration was created
182+
if not diff_sql_path.exists():
183+
logger.warning("Migration diff was not created")
184+
return
185+
logger.info(f"Migration diff created at {diff_sql_path}")
186+
187+
# 2. Run prisma db execute to apply the migration
188+
try:
189+
logger.info("Running prisma db execute to apply the migration diff...")
190+
result = subprocess.run(
191+
[
192+
"prisma",
193+
"db",
194+
"execute",
195+
"--file",
196+
str(diff_sql_path),
197+
"--schema",
198+
schema_path,
199+
],
200+
timeout=60,
201+
check=True,
202+
capture_output=True,
203+
text=True,
204+
)
205+
logger.info(f"prisma db execute stdout: {result.stdout}")
206+
logger.info("✅ Migration diff applied successfully")
207+
except subprocess.CalledProcessError as e:
208+
logger.warning(f"Failed to apply migration diff: {e.stderr}")
209+
except subprocess.TimeoutExpired:
210+
logger.warning("Migration diff application timed out.")
211+
212+
# 3. Mark all migrations as applied
109213
migration_names = ProxyExtrasDBManager._get_migration_names(migrations_dir)
110214
logger.info(f"Resolving {len(migration_names)} migrations")
111215
for migration_name in migration_names:
@@ -126,7 +230,7 @@ def _resolve_all_migrations(migrations_dir: str):
126230
)
127231

128232
@staticmethod
129-
def setup_database(schema_path: str, use_migrate: bool = False) -> bool:
233+
def setup_database(use_migrate: bool = False) -> bool:
130234
"""
131235
Set up the database using either prisma migrate or prisma db push
132236
Uses migrations from litellm-proxy-extras package
@@ -138,6 +242,7 @@ def setup_database(schema_path: str, use_migrate: bool = False) -> bool:
138242
Returns:
139243
bool: True if setup was successful, False otherwise
140244
"""
245+
schema_path = ProxyExtrasDBManager._get_prisma_dir() + "/schema.prisma"
141246
use_migrate = str_to_bool(os.getenv("USE_PRISMA_MIGRATE")) or use_migrate
142247
for attempt in range(4):
143248
original_dir = os.getcwd()
@@ -200,7 +305,9 @@ def setup_database(schema_path: str, use_migrate: bool = False) -> bool:
200305
logger.info(
201306
"Baseline migration created, resolving all migrations"
202307
)
203-
ProxyExtrasDBManager._resolve_all_migrations(migrations_dir)
308+
ProxyExtrasDBManager._resolve_all_migrations(
309+
migrations_dir, schema_path
310+
)
204311
logger.info("✅ All migrations resolved.")
205312
return True
206313
elif (

litellm/proxy/db/prisma_client.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,6 @@ def setup_database(use_migrate: bool = False) -> bool:
137137
for attempt in range(4):
138138
original_dir = os.getcwd()
139139
prisma_dir = PrismaManager._get_prisma_dir()
140-
schema_path = prisma_dir + "/schema.prisma"
141140
os.chdir(prisma_dir)
142141
try:
143142
if use_migrate:
@@ -150,11 +149,8 @@ def setup_database(use_migrate: bool = False) -> bool:
150149
return False
151150

152151
prisma_dir = PrismaManager._get_prisma_dir()
153-
schema_path = prisma_dir + "/schema.prisma"
154152

155-
return ProxyExtrasDBManager.setup_database(
156-
schema_path=schema_path, use_migrate=use_migrate
157-
)
153+
return ProxyExtrasDBManager.setup_database(use_migrate=use_migrate)
158154
else:
159155
# Use prisma db push with increased timeout
160156
subprocess.run(
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import json
2+
import os
3+
import sys
4+
import httpx
5+
import pytest
6+
import respx
7+
8+
from fastapi.testclient import TestClient
9+
10+
sys.path.insert(
11+
0, os.path.abspath("../..")
12+
) # Adds the parent directory to the system path
13+
14+
from litellm_proxy_extras.utils import ProxyExtrasDBManager
15+
16+
def test_custom_prisma_dir(monkeypatch):
17+
import tempfile
18+
# create a temp directory
19+
temp_dir = tempfile.mkdtemp()
20+
monkeypatch.setenv("LITELLM_MIGRATION_DIR", temp_dir)
21+
22+
## Check if the prisma dir is the temp directory
23+
assert ProxyExtrasDBManager._get_prisma_dir() == temp_dir
24+
25+
## Check if the schema.prisma file is in the temp directory
26+
schema_path = os.path.join(temp_dir, "schema.prisma")
27+
assert os.path.exists(schema_path)
28+
29+
## Check if the migrations dir is in the temp directory
30+
migrations_dir = os.path.join(temp_dir, "migrations")
31+
assert os.path.exists(migrations_dir)
32+

0 commit comments

Comments
 (0)