diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root index 64126bb029..004377e19b 100644 --- a/docker/Dockerfile.non_root +++ b/docker/Dockerfile.non_root @@ -59,7 +59,8 @@ RUN mkdir -p /var/lib/litellm/ui && \ mkdir -p "$folder_name" && \ mv "$html_file" "$folder_name/index.html"; \ fi; \ - done ) && \ + done && \ + touch .litellm_ui_ready ) && \ cd /app/ui/litellm-dashboard && rm -rf ./out # Build litellm wheel and place it in wheels dir (replace any PyPI wheels) diff --git a/docker/README.md b/docker/README.md index 6d81276bb4..7027a30fdd 100644 --- a/docker/README.md +++ b/docker/README.md @@ -70,9 +70,12 @@ docker compose -f docker-compose.yml -f docker-compose.hardened.yml up -d This setup: - Builds from `docker/Dockerfile.non_root` with Prisma engines and Node toolchain baked into the image. -- Runs the proxy as a non-root user with a read-only rootfs and only two writable tmpfs mounts: +- Runs the proxy as a non-root user with a read-only rootfs and only writable tmpfs mounts: - `/app/cache` (Prisma/NPM cache; backing `PRISMA_BINARY_CACHE_DIR`, `NPM_CONFIG_CACHE`, `XDG_CACHE_HOME`) - `/app/migrations` (Prisma migration workspace; backing `LITELLM_MIGRATION_DIR`) +- Pre-builds and serves the admin UI from read-only paths: + - `/var/lib/litellm/ui` (pre-restructured Next.js UI with `.litellm_ui_ready` marker) + - `/var/lib/litellm/assets` (UI logos and assets) - Routes all outbound traffic through a local Squid proxy that denies egress, so Prisma migrations must use the cached CLI and engines. You should also verify offline Prisma behaviour with: diff --git a/docs/my-website/docs/proxy/prod.md b/docs/my-website/docs/proxy/prod.md index a42d91a7d5..994788a3ad 100644 --- a/docs/my-website/docs/proxy/prod.md +++ b/docs/my-website/docs/proxy/prod.md @@ -250,11 +250,133 @@ The migrate deploy command: ### Read-only File System -If you see a `Permission denied` error, it means the LiteLLM pod is running with a read-only file system. +Running LiteLLM with `readOnlyRootFilesystem: true` is a Kubernetes security best practice that prevents container processes from writing to the root filesystem. LiteLLM fully supports this configuration. -To fix this, just set `LITELLM_MIGRATION_DIR="/path/to/writeable/directory"` in your environment. +#### Quick Fix for Permission Errors -LiteLLM will use this directory to write migration files. +If you see a `Permission denied` error, it means the LiteLLM pod is running with a read-only file system. LiteLLM needs writable directories for: +- **Database migrations**: Set `LITELLM_MIGRATION_DIR="/path/to/writable/directory"` +- **Admin UI**: Set `LITELLM_UI_PATH="/path/to/writable/directory"` +- **UI assets/logos**: Set `LITELLM_ASSETS_PATH="/path/to/writable/directory"` + +#### Complete Read-Only Filesystem Setup (Kubernetes) + +For production deployments with enhanced security, use this configuration: + +**Option 1: Using EmptyDir Volumes with InitContainer (Recommended)** + +This approach copies the pre-built UI from the Docker image to writable emptyDir volumes at pod startup. + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: litellm-proxy +spec: + template: + spec: + initContainers: + - name: setup-ui + image: ghcr.io/berriai/litellm:main-stable + command: + - sh + - -c + - | + cp -r /var/lib/litellm/ui/* /app/var/litellm/ui/ && \ + cp -r /var/lib/litellm/assets/* /app/var/litellm/assets/ + volumeMounts: + - name: ui-volume + mountPath: /app/var/litellm/ui + - name: assets-volume + mountPath: /app/var/litellm/assets + + containers: + - name: litellm + image: ghcr.io/berriai/litellm:main-stable + env: + - name: LITELLM_NON_ROOT + value: "true" + - name: LITELLM_UI_PATH + value: "/app/var/litellm/ui" + - name: LITELLM_ASSETS_PATH + value: "/app/var/litellm/assets" + - name: LITELLM_MIGRATION_DIR + value: "/app/migrations" + - name: PRISMA_BINARY_CACHE_DIR + value: "/app/cache/prisma-python/binaries" + - name: XDG_CACHE_HOME + value: "/app/cache" + securityContext: + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 101 + capabilities: + drop: + - ALL + volumeMounts: + - name: config + mountPath: /app/config.yaml + subPath: config.yaml + readOnly: true + - name: ui-volume + mountPath: /app/var/litellm/ui + - name: assets-volume + mountPath: /app/var/litellm/assets + - name: cache + mountPath: /app/cache + - name: migrations + mountPath: /app/migrations + + volumes: + - name: config + configMap: + name: litellm-config + - name: ui-volume + emptyDir: + sizeLimit: 100Mi + - name: assets-volume + emptyDir: + sizeLimit: 10Mi + - name: cache + emptyDir: + sizeLimit: 500Mi + - name: migrations + emptyDir: + sizeLimit: 64Mi +``` + +**Option 2: Without UI (API-only deployment)** + +If you don't need the admin UI, you can run with minimal configuration: + +```yaml +env: + - name: LITELLM_NON_ROOT + value: "true" + - name: LITELLM_MIGRATION_DIR + value: "/app/migrations" +securityContext: + readOnlyRootFilesystem: true +``` + +The proxy will log a warning about the UI but API endpoints will work normally. + +#### Environment Variables for Read-Only Filesystems + +| Variable | Purpose | Default | +|----------|---------|---------| +| `LITELLM_UI_PATH` | Admin UI directory | `/var/lib/litellm/ui` (Docker) | +| `LITELLM_ASSETS_PATH` | UI assets/logos | `/var/lib/litellm/assets` (Docker) | +| `LITELLM_MIGRATION_DIR` | Database migrations | Package directory | +| `PRISMA_BINARY_CACHE_DIR` | Prisma binary cache | System default | +| `XDG_CACHE_HOME` | General cache directory | System default | + +#### Important Notes + +1. **Migrations**: Always set `LITELLM_MIGRATION_DIR` to a writable emptyDir path +2. **Prisma Cache**: Set `PRISMA_BINARY_CACHE_DIR` and `XDG_CACHE_HOME` to writable paths +3. **Server Root Path**: If using a custom `server_root_path`, you must pre-process UI files in your Dockerfile as the proxy cannot modify files at runtime with read-only filesystem +4. **Automatic Detection**: The UI is automatically detected as pre-restructured if it contains a `.litellm_ui_ready` marker file (created by the official Docker images) ## 10. Use a Separate Health Check App :::info diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 6286d6dd1c..1b1e182b30 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -1051,98 +1051,236 @@ try: except FileNotFoundError: return False + def _validate_ui_directory(ui_path: str) -> bool: + """ + Verify UI directory has minimum required structure. + + Checks for: + - Directory exists + - Has index.html (main entry point) + - Has _next directory (Next.js assets) + + Returns True if UI directory appears valid and servable. + """ + if not os.path.isdir(ui_path): + return False + + # Must have main index.html + if not os.path.exists(os.path.join(ui_path, "index.html")): + return False + + # Must have _next directory with Next.js assets + next_dir = os.path.join(ui_path, "_next") + if not os.path.isdir(next_dir): + return False + + return True + + def _is_ui_pre_restructured(ui_dir: str) -> bool: + """ + Detect if UI directory is already pre-restructured and ready to serve. + + Returns True if: + 1. Marker file .litellm_ui_ready exists (created by Dockerfile), OR + 2. Restructuring pattern detected (subdirectories with index.html inside) + + This allows skipping copy/restructure operations on read-only filesystems. + """ + if not os.path.isdir(ui_dir): + return False + + # Primary signal: marker file created by Dockerfile + marker_file = os.path.join(ui_dir, ".litellm_ui_ready") + if os.path.exists(marker_file): + verbose_proxy_logger.debug(f"Found UI ready marker: {marker_file}") + return True + + # Fallback signal: Detect restructuring pattern + # After restructuring, routes exist as directories with index.html inside + # (e.g., login/index.html instead of login.html) + # Check for main index.html first (basic UI structure requirement) + if not os.path.exists(os.path.join(ui_dir, "index.html")): + return False + + # Look for ANY subdirectory with index.html (proves restructuring happened) + # Ignore directories starting with _ (Next.js internals like _next) + try: + for entry in os.scandir(ui_dir): + if entry.is_dir() and not entry.name.startswith("_"): + index_path = os.path.join(entry.path, "index.html") + if os.path.exists(index_path): + # Found at least one restructured route - this proves the pattern + verbose_proxy_logger.debug( + f"Detected restructured UI via pattern: found {entry.name}/index.html" + ) + return True + except (PermissionError, OSError) as e: + verbose_proxy_logger.debug( + f"Could not scan {ui_dir} for restructuring detection: {e}" + ) + return False + + # No restructured routes found + return False + + def _try_populate_ui_directory( + source_path: str, target_path: str + ) -> tuple[bool, str]: + """ + Attempt to populate target UI directory from source. + + Returns: (success: bool, error_message: str) + """ + try: + os.makedirs(target_path, exist_ok=True) + if not _dir_has_content(target_path) and _dir_has_content(source_path): + shutil.copytree( + source_path, + target_path, + dirs_exist_ok=True, + ) + verbose_proxy_logger.info(f"Successfully populated UI at {target_path}") + return True, "" + else: + return False, "Source or target directory state invalid" + except (PermissionError, OSError) as e: + return False, str(e) + # Use a writable runtime UI directory whenever possible. # 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" - # Only use runtime UI path in Docker/non-root environments - # In local development, use the packaged UI directly + # Determine runtime UI path + # Priority: LITELLM_UI_PATH env var > default path based on is_non_root if is_non_root: - # Use /var/lib/litellm/ui for Docker (more secure than /tmp) - runtime_ui_path = "/var/lib/litellm/ui" + default_runtime_ui_path = "/var/lib/litellm/ui" + else: + default_runtime_ui_path = packaged_ui_path - if _dir_has_content(runtime_ui_path): + runtime_ui_path = os.getenv("LITELLM_UI_PATH", default_runtime_ui_path) + + # Validate packaged UI before proceeding + if not _validate_ui_directory(packaged_ui_path): + verbose_proxy_logger.error( + f"Packaged UI at {packaged_ui_path} is invalid or incomplete. " + f"UI may not function correctly." + ) + + # Decision tree for UI path selection: + # 1. If runtime path == packaged path: use packaged UI directly + # 2. If runtime UI exists and is pre-restructured: use it + # 3. If runtime UI exists but not restructured: use it (will restructure later) + # 4. If runtime UI missing: try to populate from packaged UI + # 4a. If population succeeds: use runtime UI + # 4b. If population fails: fall back to packaged UI + + should_use_runtime_path = runtime_ui_path != packaged_ui_path + + if should_use_runtime_path: + is_pre_restructured = _is_ui_pre_restructured(runtime_ui_path) + has_content = _dir_has_content(runtime_ui_path) + + # Case 2: Runtime UI exists and is ready + if has_content and is_pre_restructured: verbose_proxy_logger.info( - f"Using pre-built UI for non-root Docker: {runtime_ui_path}" + f"Using pre-restructured UI at {runtime_ui_path}" ) ui_path = runtime_ui_path + + # Case 3: Runtime UI exists but needs restructuring + elif has_content and not is_pre_restructured: + verbose_proxy_logger.warning( + f"UI at {runtime_ui_path} has content but is not properly restructured. " + f"Will attempt to restructure in place." + ) + ui_path = runtime_ui_path + + # Case 4: Runtime UI missing - try to populate else: - verbose_proxy_logger.error( - f"UI not found at {runtime_ui_path}. Attempting to populate it from packaged UI." - ) - verbose_proxy_logger.error( - f"Path exists: {os.path.exists(runtime_ui_path)}, Has content: {_dir_has_content(runtime_ui_path)}" + verbose_proxy_logger.info( + f"UI not found at {runtime_ui_path}. Attempting to populate from packaged UI." ) - 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}" - ) + success, error = _try_populate_ui_directory( + packaged_ui_path, runtime_ui_path + ) + + if success: + # Case 4a: Population succeeded + ui_path = runtime_ui_path else: - if _dir_has_content(runtime_ui_path): - verbose_proxy_logger.info( - f"Using populated UI for non-root Docker: {runtime_ui_path}" - ) - ui_path = runtime_ui_path + # Case 4b: Population failed - fall back to packaged UI + verbose_proxy_logger.warning( + f"Failed to populate UI at {runtime_ui_path}: {error}. " + f"Falling back to packaged UI at {packaged_ui_path}. " + f"For read-only deployments, pre-build UI in Dockerfile " + f"or set LITELLM_UI_PATH to a writable emptyDir volume." + ) + ui_path = packaged_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}" - ) + # Case 1: Using packaged UI directly (local development) + verbose_proxy_logger.info(f"Using packaged UI directory: {packaged_ui_path}") ui_path = packaged_ui_path - # Only modify files if a custom server root path is set + + # Validate final UI path + if not _validate_ui_directory(ui_path): + verbose_proxy_logger.error( + f"Selected UI path {ui_path} is invalid or incomplete. UI may not work correctly." + ) + + # Only modify files if a custom server root path is set AND filesystem is writable if server_root_path and server_root_path != "/": - # Iterate through files in the UI directory - for root, dirs, files in os.walk(ui_path): - for filename in files: - file_path = os.path.join(root, filename) - # Skip binary files and files that don't need path replacement - if filename.endswith( - ( - ".png", - ".jpg", - ".jpeg", - ".gif", - ".ico", - ".woff", - ".woff2", - ".ttf", - ".eot", - ) - ): - continue - try: - with open(file_path, "r", encoding="utf-8") as f: - content = f.read() + # Check if UI path is writable + is_writable = os.access(ui_path, os.W_OK) - # Replace the asset prefix with the server root path - modified_content = content.replace( - f"{litellm_asset_prefix}", - f"{server_root_path}", - ) + if not is_writable: + verbose_proxy_logger.warning( + f"Cannot apply server_root_path replacements to UI at {ui_path}: " + f"path is not writable. Ensure server_root_path is '/' or pre-process " + f"UI files in Dockerfile with custom server_root_path." + ) + else: + # Iterate through files in the UI directory + for root, dirs, files in os.walk(ui_path): + for filename in files: + file_path = os.path.join(root, filename) + # Skip binary files and files that don't need path replacement + if filename.endswith( + ( + ".png", + ".jpg", + ".jpeg", + ".gif", + ".ico", + ".woff", + ".woff2", + ".ttf", + ".eot", + ) + ): + continue + try: + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() - # Replace the /.well-known/litellm-ui-config with the server root path - modified_content = modified_content.replace( - "/litellm/.well-known/litellm-ui-config", - f"{server_root_path}/.well-known/litellm-ui-config", - ) + # Replace the asset prefix with the server root path + modified_content = content.replace( + f"{litellm_asset_prefix}", + f"{server_root_path}", + ) - with open(file_path, "w", encoding="utf-8") as f: - f.write(modified_content) - except UnicodeDecodeError: - # Skip binary files that can't be decoded - continue + # Replace the /.well-known/litellm-ui-config with the server root path + modified_content = modified_content.replace( + "/litellm/.well-known/litellm-ui-config", + f"{server_root_path}/.well-known/litellm-ui-config", + ) + + with open(file_path, "w", encoding="utf-8") as f: + f.write(modified_content) + except (UnicodeDecodeError, PermissionError, OSError): + # Skip binary files or files we can't write to + continue # # Mount the _next directory at the root level app.mount( @@ -1186,14 +1324,22 @@ try: continue # Handle HTML file restructuring - # Always restructure the directory we actually serve. - # This is critical for extensionless routes like /ui/login (expects login/index.html). - # In development, we restructure directly in _experimental/out. - # In non-root Docker, we restructure in /var/lib/litellm/ui. + # Only restructure if: + # 1. UI is not already pre-restructured + # 2. Filesystem is writable try: - if is_non_root and ui_path == "/var/lib/litellm/ui": + is_pre_restructured = _is_ui_pre_restructured(ui_path) + is_writable = os.access(ui_path, os.W_OK) + + if is_pre_restructured: verbose_proxy_logger.info( - f"Skipping runtime UI restructuring for non-root Docker. UI at {ui_path} is pre-restructured." + f"Skipping UI restructuring: {ui_path} is already pre-restructured" + ) + elif not is_writable: + verbose_proxy_logger.warning( + f"Cannot restructure UI at {ui_path}: path is not writable. " + f"UI may not work correctly for extensionless routes. " + f"Pre-build and restructure UI in Dockerfile for read-only deployments." ) else: _restructure_ui_html_files(ui_path) @@ -10294,18 +10440,34 @@ async 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 = "/var/lib/litellm/assets" if is_non_root else current_dir - if is_non_root: - os.makedirs(assets_dir, exist_ok=True) + # Determine assets directory + # Priority: LITELLM_ASSETS_PATH env var > default based on is_non_root + default_assets_dir = "/var/lib/litellm/assets" if is_non_root else current_dir + assets_dir = os.getenv("LITELLM_ASSETS_PATH", default_assets_dir) + # Try to create assets_dir if it doesn't exist (simple try/except approach) + if not os.path.exists(assets_dir): + try: + os.makedirs(assets_dir, exist_ok=True) + verbose_proxy_logger.debug(f"Created assets directory at {assets_dir}") + except (PermissionError, OSError) as e: + verbose_proxy_logger.warning( + f"Cannot create assets directory at {assets_dir}: {e}. " + f"Logo caching may not work. Using current directory for assets." + ) + assets_dir = current_dir + + # Determine default logo path default_logo = ( - os.path.join(assets_dir, "logo.jpg") if is_non_root else default_site_logo + os.path.join(assets_dir, "logo.jpg") + if assets_dir != current_dir + else default_site_logo ) - if is_non_root and not os.path.exists(default_logo): + if assets_dir != current_dir and not os.path.exists(default_logo): default_logo = default_site_logo - cache_dir = assets_dir if is_non_root else current_dir + cache_dir = assets_dir if os.access(assets_dir, os.W_OK) else current_dir cache_path = os.path.join(cache_dir, "cached_logo.jpg") # [OPTIMIZATION] Check if the cached image exists first diff --git a/tests/proxy_unit_tests/test_ui_path_detection.py b/tests/proxy_unit_tests/test_ui_path_detection.py new file mode 100644 index 0000000000..72ee7770f9 --- /dev/null +++ b/tests/proxy_unit_tests/test_ui_path_detection.py @@ -0,0 +1,157 @@ +""" +Unit tests for UI path detection and configuration. + +Tests the new LITELLM_UI_PATH and LITELLM_ASSETS_PATH functionality +for read-only filesystem support. + +Note: Tests involving proxy_server imports are intentionally minimal +to avoid long module load times during testing. +""" + +import os +import tempfile +from pathlib import Path +from unittest import mock + +import pytest + + +class TestUIPathEnvironmentVariable: + """Test LITELLM_UI_PATH environment variable handling.""" + + def test_custom_ui_path_env_var(self): + """Test that LITELLM_UI_PATH overrides default.""" + custom_path = "/custom/ui/path" + + with mock.patch.dict( + os.environ, {"LITELLM_UI_PATH": custom_path, "LITELLM_NON_ROOT": "true"} + ): + is_non_root = os.getenv("LITELLM_NON_ROOT", "").lower() == "true" + default_runtime_ui_path = ( + "/var/lib/litellm/ui" if is_non_root else "/default/packaged/path" + ) + runtime_ui_path = os.getenv("LITELLM_UI_PATH", default_runtime_ui_path) + + assert runtime_ui_path == custom_path + + def test_default_ui_path_non_root(self): + """Test default UI path in non-root mode.""" + with mock.patch.dict( + os.environ, {"LITELLM_NON_ROOT": "true"}, clear=False + ): + # Clear LITELLM_UI_PATH if it exists + env_copy = os.environ.copy() + if "LITELLM_UI_PATH" in env_copy: + del env_copy["LITELLM_UI_PATH"] + + with mock.patch.dict(os.environ, env_copy, clear=True): + is_non_root = os.getenv("LITELLM_NON_ROOT", "").lower() == "true" + default_runtime_ui_path = ( + "/var/lib/litellm/ui" + if is_non_root + else "/default/packaged/path" + ) + runtime_ui_path = os.getenv( + "LITELLM_UI_PATH", default_runtime_ui_path + ) + + assert runtime_ui_path == "/var/lib/litellm/ui" + + +class TestAssetsPathEnvironmentVariable: + """Test LITELLM_ASSETS_PATH environment variable handling.""" + + def test_custom_assets_path_env_var(self): + """Test that LITELLM_ASSETS_PATH overrides default.""" + custom_path = "/custom/assets/path" + + with mock.patch.dict( + os.environ, + {"LITELLM_ASSETS_PATH": custom_path, "LITELLM_NON_ROOT": "true"}, + ): + is_non_root = os.getenv("LITELLM_NON_ROOT", "").lower() == "true" + default_assets_dir = ( + "/var/lib/litellm/assets" if is_non_root else "/default/current/dir" + ) + assets_dir = os.getenv("LITELLM_ASSETS_PATH", default_assets_dir) + + assert assets_dir == custom_path + + def test_default_assets_path_non_root(self): + """Test default assets path in non-root mode.""" + env_copy = os.environ.copy() + env_copy["LITELLM_NON_ROOT"] = "true" + if "LITELLM_ASSETS_PATH" in env_copy: + del env_copy["LITELLM_ASSETS_PATH"] + + with mock.patch.dict(os.environ, env_copy, clear=True): + is_non_root = os.getenv("LITELLM_NON_ROOT", "").lower() == "true" + default_assets_dir = ( + "/var/lib/litellm/assets" if is_non_root else "/default/current/dir" + ) + assets_dir = os.getenv("LITELLM_ASSETS_PATH", default_assets_dir) + + assert assets_dir == "/var/lib/litellm/assets" + + +class TestUIDetectionLogic: + """Test UI pre-restructured detection logic without importing proxy_server.""" + + def setup_method(self): + """Create temporary directory for testing.""" + self.temp_dir = tempfile.mkdtemp() + + def teardown_method(self): + """Clean up temporary directory.""" + import shutil + + if os.path.exists(self.temp_dir): + shutil.rmtree(self.temp_dir) + + def test_marker_file_exists(self): + """Test marker file detection logic.""" + marker_path = os.path.join(self.temp_dir, ".litellm_ui_ready") + Path(marker_path).touch() + + # Verify marker file exists + assert os.path.exists(marker_path) + + def test_structural_routes_exist(self): + """Test structural detection logic.""" + routes = ["login", "guardrails", "logs"] + for route in routes: + route_dir = os.path.join(self.temp_dir, route) + os.makedirs(route_dir, exist_ok=True) + index_html = os.path.join(route_dir, "index.html") + Path(index_html).touch() + + # Verify routes exist + found_routes = 0 + expected_routes = ["login", "guardrails", "logs", "api-reference"] + for route in expected_routes: + route_index = os.path.join(self.temp_dir, route, "index.html") + if os.path.exists(route_index): + found_routes += 1 + + assert found_routes >= 3 + + def test_writability_check(self): + """Test that os.access() correctly detects writable directories.""" + # Should be writable + assert os.access(self.temp_dir, os.W_OK) is True + + # Create a directory we can't write to (platform-dependent) + if os.name != "nt": # Skip on Windows + readonly_dir = os.path.join(self.temp_dir, "readonly") + os.makedirs(readonly_dir) + os.chmod(readonly_dir, 0o444) # Read-only + + # Should not be writable + assert os.access(readonly_dir, os.W_OK) is False + + # Restore permissions for cleanup + os.chmod(readonly_dir, 0o755) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])