diff --git a/src/backend/base/langflow/initial_setup/setup.py b/src/backend/base/langflow/initial_setup/setup.py index e749cdc102..477a8bdc6b 100644 --- a/src/backend/base/langflow/initial_setup/setup.py +++ b/src/backend/base/langflow/initial_setup/setup.py @@ -675,10 +675,30 @@ def get_project_data(project): async def update_project_file(project_path: anyio.Path, project: dict, updated_project_data) -> None: + """Update starter project JSON file with new data. + + This function attempts to write updated project data back to the source file. + In containerized environments with read-only filesystems (e.g., Kubernetes with + readOnlyRootFilesystem: true), the write will fail gracefully since the database + is the source of truth for project data. + + Args: + project_path: Path to the project JSON file + project: Project dictionary to update + updated_project_data: New project data to write + """ project["data"] = updated_project_data - async with aiofiles.open(str(project_path), "w", encoding="utf-8") as f: - await f.write(orjson.dumps(project, option=ORJSON_OPTIONS).decode()) - await logger.adebug(f"Updated starter project {project['name']} file") + try: + async with aiofiles.open(str(project_path), "w", encoding="utf-8") as f: + await f.write(orjson.dumps(project, option=ORJSON_OPTIONS).decode()) + await logger.adebug(f"Updated starter project {project['name']} file") + except OSError as e: + # Handle read-only filesystem (common in containerized environments) + # The database update is the important part - file updates are optional + await logger.adebug( + f"Could not update starter project file {project['name']} (read-only filesystem): {e}. " + "This is expected in containerized environments with read-only root filesystem." + ) def update_existing_project( diff --git a/src/backend/tests/unit/test_initial_setup.py b/src/backend/tests/unit/test_initial_setup.py index 8ca6607d64..931743869e 100644 --- a/src/backend/tests/unit/test_initial_setup.py +++ b/src/backend/tests/unit/test_initial_setup.py @@ -714,3 +714,77 @@ def test_update_projects_resolves_parser_via_component_type_alias(): updated_project = update_projects_components_with_latest_component_versions(project_data, all_types_dict) updated_code = updated_project["nodes"][0]["data"]["node"]["template"]["code"]["value"] assert updated_code == "new_parser_code_v2" + + +# ==================== Update Project File Tests ==================== + + +async def test_update_project_file_success(): + """Test that update_project_file successfully writes to a writable path.""" + from langflow.initial_setup.setup import update_project_file + + with tempfile.TemporaryDirectory() as temp_dir: + project_path = Path(temp_dir) / "test_project.json" + project = {"name": "Test Project", "data": {"old": "data"}} + updated_data = {"new": "data"} + + await update_project_file(project_path, project, updated_data) + + # Verify the file was written + assert await project_path.exists() + content = await project_path.read_text(encoding="utf-8") + import orjson + + written_project = orjson.loads(content) + assert written_project["data"] == updated_data + assert written_project["name"] == "Test Project" + + +async def test_update_project_file_readonly_filesystem(): + """Test that update_project_file handles read-only filesystem gracefully.""" + from langflow.initial_setup.setup import update_project_file + + project_path = Path("/nonexistent/readonly/path/test_project.json") + project = {"name": "Test Project", "data": {"old": "data"}} + updated_data = {"new": "data"} + + # This should NOT raise an exception - it should handle the error gracefully + await update_project_file(project_path, project, updated_data) + + # Verify the project dict was still updated (in-memory) + assert project["data"] == updated_data + + +async def test_update_project_file_permission_denied(): + """Test that update_project_file handles permission denied gracefully.""" + from langflow.initial_setup.setup import update_project_file + + with tempfile.TemporaryDirectory() as temp_dir: + project_path = Path(temp_dir) / "test_project.json" + project = {"name": "Test Project", "data": {"old": "data"}} + updated_data = {"new": "data"} + + # Mock aiofiles.open to raise OSError (permission denied) + with patch("langflow.initial_setup.setup.aiofiles.open") as mock_open: + mock_open.side_effect = OSError(13, "Permission denied") + + # Should not raise + await update_project_file(project_path, project, updated_data) + + # Verify the project dict was still updated (in-memory) + assert project["data"] == updated_data + + +async def test_update_project_file_logs_debug_on_oserror(): + """Test that update_project_file logs a debug message on OSError.""" + from langflow.initial_setup.setup import update_project_file + + project_path = Path("/nonexistent/readonly/path/test_project.json") + project = {"name": "Test Project", "data": {"old": "data"}} + updated_data = {"new": "data"} + + with patch("langflow.initial_setup.setup.logger") as mock_logger: + mock_logger.adebug = AsyncMock() + await update_project_file(project_path, project, updated_data) + # Verify debug log was called (either success or error path) + assert mock_logger.adebug.called