diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root index 7e9147a124..af1bb5b202 100644 --- a/docker/Dockerfile.non_root +++ b/docker/Dockerfile.non_root @@ -40,7 +40,7 @@ COPY . . ENV LITELLM_NON_ROOT=true # Build Admin UI using the upstream command order while keeping a single RUN layer -RUN mkdir -p /tmp/litellm_ui && \ +RUN mkdir -p /var/lib/litellm/ui && \ npm install -g npm@latest && npm cache clean --force && \ cd /app/ui/litellm-dashboard && \ if [ -f "/app/enterprise/enterprise_ui/enterprise_colors.json" ]; then \ @@ -49,10 +49,10 @@ RUN mkdir -p /tmp/litellm_ui && \ rm -f package-lock.json && \ npm install --legacy-peer-deps && \ npm run build && \ - cp -r /app/ui/litellm-dashboard/out/* /tmp/litellm_ui/ && \ - mkdir -p /tmp/litellm_assets && \ - cp /app/litellm/proxy/logo.jpg /tmp/litellm_assets/logo.jpg && \ - ( cd /tmp/litellm_ui && \ + cp -r /app/ui/litellm-dashboard/out/* /var/lib/litellm/ui/ && \ + mkdir -p /var/lib/litellm/assets && \ + cp /app/litellm/proxy/logo.jpg /var/lib/litellm/assets/logo.jpg && \ + ( cd /var/lib/litellm/ui && \ for html_file in *.html; do \ if [ "$html_file" != "index.html" ] && [ -f "$html_file" ]; then \ folder_name="${html_file%.html}" && \ @@ -111,8 +111,8 @@ COPY --from=builder /app/docker/entrypoint.sh /app/docker/prod_entrypoint.sh /ap COPY --from=builder /app/docker/supervisord.conf /etc/supervisord.conf COPY --from=builder /app/schema.prisma /app/ COPY --from=builder /wheels/ /wheels/ -COPY --from=builder /tmp/litellm_ui /tmp/litellm_ui -COPY --from=builder /tmp/litellm_assets /tmp/litellm_assets +COPY --from=builder /var/lib/litellm/ui /var/lib/litellm/ui +COPY --from=builder /var/lib/litellm/assets /var/lib/litellm/assets COPY --from=builder /app/.cache /app/.cache COPY --from=builder /app/litellm-proxy-extras /app/litellm-proxy-extras COPY --from=builder \ @@ -145,8 +145,8 @@ RUN pip install --no-index --find-links=/wheels/ -r requirements.txt && \ # Permissions, cleanup, and Prisma prep RUN chmod +x docker/entrypoint.sh docker/prod_entrypoint.sh && \ - mkdir -p /nonexistent /.npm /tmp/litellm_assets /tmp/litellm_ui && \ - chown -R nobody:nogroup /app /tmp/litellm_ui /tmp/litellm_assets /nonexistent /.npm && \ + mkdir -p /nonexistent /.npm /var/lib/litellm/assets /var/lib/litellm/ui && \ + chown -R nobody:nogroup /app /var/lib/litellm/ui /var/lib/litellm/assets /nonexistent /.npm && \ pip uninstall jwt -y || true && \ pip uninstall PyJWT -y || true && \ pip install --no-index --find-links=/wheels/ PyJWT==2.10.1 --no-cache-dir && \ @@ -156,11 +156,11 @@ RUN chmod +x docker/entrypoint.sh docker/prod_entrypoint.sh && \ LITELLM_PKG_MIGRATIONS_PATH="$(python -c 'import os, litellm_proxy_extras; print(os.path.dirname(litellm_proxy_extras.__file__))' 2>/dev/null || echo '')/migrations" && \ [ -n "$LITELLM_PKG_MIGRATIONS_PATH" ] && chown -R nobody:nogroup $LITELLM_PKG_MIGRATIONS_PATH && \ LITELLM_PROXY_EXTRAS_PATH=$(python -c "import os, litellm_proxy_extras; print(os.path.dirname(litellm_proxy_extras.__file__))" 2>/dev/null || echo "") && \ - chgrp -R 0 $PRISMA_PATH /tmp/litellm_ui /tmp/litellm_assets && \ + chgrp -R 0 $PRISMA_PATH /var/lib/litellm/ui /var/lib/litellm/assets && \ [ -n "$LITELLM_PROXY_EXTRAS_PATH" ] && chgrp -R 0 $LITELLM_PROXY_EXTRAS_PATH || true && \ - chmod -R g=u $PRISMA_PATH /tmp/litellm_ui /tmp/litellm_assets && \ + chmod -R g=u $PRISMA_PATH /var/lib/litellm/ui /var/lib/litellm/assets && \ [ -n "$LITELLM_PROXY_EXTRAS_PATH" ] && chmod -R g=u $LITELLM_PROXY_EXTRAS_PATH || true && \ - chmod -R g+w $PRISMA_PATH /tmp/litellm_ui /tmp/litellm_assets && \ + chmod -R g+w $PRISMA_PATH /var/lib/litellm/ui /var/lib/litellm/assets && \ [ -n "$LITELLM_PROXY_EXTRAS_PATH" ] && chmod -R g+w $LITELLM_PROXY_EXTRAS_PATH || true && \ chmod -R g+rX $PRISMA_PATH && \ chmod -R g+rX /app/.cache && \ diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index f754e52796..a1be8153b2 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -946,20 +946,19 @@ try: # This prevents mutating the packaged UI directory (e.g. site-packages or the repo checkout) # and ensures extensionless routes like /ui/login work via /index.html. is_non_root = os.getenv("LITELLM_NON_ROOT", "").lower() == "true" - runtime_ui_path = "/tmp/litellm_ui" - if _dir_has_content(runtime_ui_path): - if is_non_root: + # Only use runtime UI path in Docker/non-root environments + # In local development, use the packaged UI directly + if is_non_root: + # Use /var/lib/litellm/ui for Docker (more secure than /tmp) + runtime_ui_path = "/var/lib/litellm/ui" + + if _dir_has_content(runtime_ui_path): verbose_proxy_logger.info( f"Using pre-built UI for non-root Docker: {runtime_ui_path}" ) + ui_path = runtime_ui_path else: - verbose_proxy_logger.info( - f"Using cached runtime UI directory: {runtime_ui_path}" - ) - ui_path = runtime_ui_path - else: - if is_non_root: verbose_proxy_logger.error( f"UI not found at {runtime_ui_path}. Attempting to populate it from packaged UI." ) @@ -967,32 +966,32 @@ try: f"Path exists: {os.path.exists(runtime_ui_path)}, Has content: {_dir_has_content(runtime_ui_path)}" ) - try: - os.makedirs(runtime_ui_path, exist_ok=True) - if not _dir_has_content(runtime_ui_path) and _dir_has_content( - packaged_ui_path - ): - shutil.copytree( - packaged_ui_path, - runtime_ui_path, - dirs_exist_ok=True, - ) - except Exception as e: - if is_non_root: + try: + os.makedirs(runtime_ui_path, exist_ok=True) + if not _dir_has_content(runtime_ui_path) and _dir_has_content( + packaged_ui_path + ): + shutil.copytree( + packaged_ui_path, + runtime_ui_path, + dirs_exist_ok=True, + ) + except Exception as e: verbose_proxy_logger.exception( f"Failed to populate runtime UI directory {runtime_ui_path} from {packaged_ui_path}: {e}" ) - else: - if _dir_has_content(runtime_ui_path): - if is_non_root: + else: + if _dir_has_content(runtime_ui_path): verbose_proxy_logger.info( f"Using populated UI for non-root Docker: {runtime_ui_path}" ) - else: - verbose_proxy_logger.info( - f"Using populated runtime UI directory: {runtime_ui_path}" - ) - ui_path = runtime_ui_path + ui_path = runtime_ui_path + else: + # Local development: use packaged UI directly, no runtime copy needed + verbose_proxy_logger.info( + f"Using packaged UI directory for local development: {packaged_ui_path}" + ) + ui_path = packaged_ui_path # Only modify files if a custom server root path is set if server_root_path and server_root_path != "/": @@ -8885,7 +8884,7 @@ def get_image(): default_site_logo = os.path.join(current_dir, "logo.jpg") is_non_root = os.getenv("LITELLM_NON_ROOT", "").lower() == "true" - assets_dir = "/tmp/litellm_assets" if is_non_root else current_dir + assets_dir = "/var/lib/litellm/assets" if is_non_root else current_dir if is_non_root: os.makedirs(assets_dir, exist_ok=True) diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index fd7036b994..dc0dec3437 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -273,6 +273,10 @@ def test_sso_key_generate_shows_deprecation_banner(client_no_auth, monkeypatch): def test_restructure_ui_html_files_handles_nested_routes(tmp_path): + """ + Test that _restructure_ui_html_files correctly restructures HTML files. + Note: This function is only called when is_non_root is True (ui_path != packaged_ui_path). + """ from litellm.proxy import proxy_server ui_root = tmp_path / "ui" @@ -306,7 +310,10 @@ def test_restructure_ui_html_files_handles_nested_routes(tmp_path): def test_ui_extensionless_route_requires_restructure(tmp_path): - """Regression for non-root fallback: /ui/login expects login/index.html.""" + """ + Regression for non-root fallback: /ui/login expects login/index.html. + Note: Restructuring only happens when is_non_root is True (ui_path != packaged_ui_path). + """ from litellm.proxy import proxy_server @@ -331,6 +338,49 @@ def test_ui_extensionless_route_requires_restructure(tmp_path): assert "login" in response.text +def test_restructure_only_happens_when_non_root(monkeypatch): + """ + Test that restructuring logic only executes when LITELLM_NON_ROOT is true. + When is_non_root is False, ui_path == packaged_ui_path, so restructuring is skipped. + """ + # Test Case 1: is_non_root is True - ui_path != packaged_ui_path, so restructuring should happen + monkeypatch.setenv("LITELLM_NON_ROOT", "true") + + runtime_ui_path = "/var/lib/litellm/ui" + packaged_ui_path = "/some/packaged/ui/path" + + # Simulate the logic from proxy_server.py + is_non_root = os.getenv("LITELLM_NON_ROOT", "").lower() == "true" + if is_non_root: + ui_path = runtime_ui_path + else: + ui_path = packaged_ui_path + + # This is the condition that determines if restructuring happens + should_restructure = ui_path != packaged_ui_path + + assert is_non_root is True + assert should_restructure is True + assert ui_path == runtime_ui_path + + # Test Case 2: is_non_root is False - ui_path == packaged_ui_path, so restructuring should NOT happen + monkeypatch.delenv("LITELLM_NON_ROOT", raising=False) + + # Simulate the logic from proxy_server.py + is_non_root = os.getenv("LITELLM_NON_ROOT", "").lower() == "true" + if is_non_root: + ui_path = runtime_ui_path + else: + ui_path = packaged_ui_path + + # This is the condition that determines if restructuring happens + should_restructure = ui_path != packaged_ui_path + + assert is_non_root is False + assert should_restructure is False + assert ui_path == packaged_ui_path + + @pytest.mark.asyncio async def test_initialize_scheduled_jobs_credentials(monkeypatch): """ @@ -2856,9 +2906,9 @@ def test_root_redirect_when_docs_url_not_root_and_redirect_url_set(monkeypatch): assert response.headers["location"] == test_redirect_url -def test_get_image_non_root_uses_tmp_assets_dir(monkeypatch): +def test_get_image_non_root_uses_var_lib_assets_dir(monkeypatch): """ - Test that get_image uses /tmp/litellm_assets when LITELLM_NON_ROOT is true. + Test that get_image uses /var/lib/litellm/assets when LITELLM_NON_ROOT is true. """ from unittest.mock import patch @@ -2887,14 +2937,14 @@ def test_get_image_non_root_uses_tmp_assets_dir(monkeypatch): # Call the function get_image() - # Verify makedirs was called with /tmp/litellm_assets - mock_makedirs.assert_called_once_with("/tmp/litellm_assets", exist_ok=True) + # Verify makedirs was called with /var/lib/litellm/assets + mock_makedirs.assert_called_once_with("/var/lib/litellm/assets", exist_ok=True) def test_get_image_non_root_fallback_to_default_logo(monkeypatch): """ Test that get_image falls back to default_site_logo when logo doesn't exist - in /tmp/litellm_assets for non-root case. + in /var/lib/litellm/assets for non-root case. """ from unittest.mock import patch @@ -2904,13 +2954,13 @@ def test_get_image_non_root_fallback_to_default_logo(monkeypatch): monkeypatch.setenv("LITELLM_NON_ROOT", "true") monkeypatch.delenv("UI_LOGO_PATH", raising=False) - # Track path.exists calls to verify it checks /tmp/litellm_assets/logo.jpg + # Track path.exists calls to verify it checks /var/lib/litellm/assets/logo.jpg exists_calls = [] def exists_side_effect(path): exists_calls.append(path) - # Return False for /tmp/litellm_assets/logo.jpg to trigger fallback - if "/tmp/litellm_assets/logo.jpg" in path: + # Return False for /var/lib/litellm/assets/logo.jpg to trigger fallback + if "/var/lib/litellm/assets/logo.jpg" in path: return False return True @@ -2933,13 +2983,13 @@ def test_get_image_non_root_fallback_to_default_logo(monkeypatch): # Call the function get_image() - # Verify makedirs was called with /tmp/litellm_assets - mock_makedirs.assert_called_once_with("/tmp/litellm_assets", exist_ok=True) + # Verify makedirs was called with /var/lib/litellm/assets + mock_makedirs.assert_called_once_with("/var/lib/litellm/assets", exist_ok=True) - # Verify that exists was called to check /tmp/litellm_assets/logo.jpg - tmp_logo_path = "/tmp/litellm_assets/logo.jpg" - assert any(tmp_logo_path in str(call) for call in exists_calls), \ - f"Should check if {tmp_logo_path} exists" + # Verify that exists was called to check /var/lib/litellm/assets/logo.jpg + assets_logo_path = "/var/lib/litellm/assets/logo.jpg" + assert any(assets_logo_path in str(call) for call in exists_calls), \ + f"Should check if {assets_logo_path} exists" # Verify FileResponse was called (with fallback logo) assert mock_file_response.called, "FileResponse should be called" @@ -2976,12 +3026,12 @@ def test_get_image_root_case_uses_current_dir(monkeypatch): # Call the function get_image() - # Verify makedirs was NOT called with /tmp/litellm_assets (should not create it for root case) - tmp_assets_calls = [ + # Verify makedirs was NOT called with /var/lib/litellm/assets (should not create it for root case) + var_lib_assets_calls = [ call for call in mock_makedirs.call_args_list - if "/tmp/litellm_assets" in str(call) + if "/var/lib/litellm/assets" in str(call) ] - assert len(tmp_assets_calls) == 0, "Should not create /tmp/litellm_assets for root case" + assert len(var_lib_assets_calls) == 0, "Should not create /var/lib/litellm/assets for root case" # Verify FileResponse was called assert mock_file_response.called, "FileResponse should be called"