diff --git a/Dockerfile.langflow b/Dockerfile.langflow index 122c8e5ea..d727620d4 100644 --- a/Dockerfile.langflow +++ b/Dockerfile.langflow @@ -6,6 +6,13 @@ FROM langflowai/langflow:1.8.0 RUN pip install uv \ && mkdir -p /app/langflow-data +# Copy the entrypoint before switching to root so the COPY runs as uid=1000 +# (which owns /app), then switch to root so the entrypoint can correct +# bind-mount permissions at runtime before dropping back to uid=1000. +COPY --chmod=755 scripts/langflow-entrypoint.py /usr/local/bin/langflow-entrypoint +USER root + EXPOSE 7860 +ENTRYPOINT ["langflow-entrypoint"] CMD ["langflow", "run", "--host", "0.0.0.0", "--port", "7860"] diff --git a/Makefile b/Makefile index 9cebd1b8f..42715c82e 100644 --- a/Makefile +++ b/Makefile @@ -322,6 +322,7 @@ help_utils: ## Show utility commands ensure-langflow-data: ## Create the langflow-data directory if it does not exist @mkdir -p langflow-data + @chmod 777 langflow-data dev: ensure-langflow-data ## Start full stack with GPU support @echo "$(YELLOW)Starting OpenRAG with GPU support...$(NC)" diff --git a/scripts/langflow-entrypoint.py b/scripts/langflow-entrypoint.py new file mode 100644 index 000000000..12370ca96 --- /dev/null +++ b/scripts/langflow-entrypoint.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Entrypoint for the OpenRAG Langflow container. + +Runs as root to correct /app/langflow-data bind-mount permissions, then drops +to uid/gid 1000 (langflow user) before exec-ing the main process. + +On macOS with Podman the virtiofs layer does not faithfully propagate +host-side chmod into the container, so permissions must be fixed from +inside the container after the mount is established. +""" +import os +import pathlib +import pwd +import sys + +data_dir = pathlib.Path("/app/langflow-data") + +try: + data_dir.chmod(0o777) +except OSError: + pass + +# Look up uid 1000's passwd entry so we can restore HOME and USER correctly +# after dropping privileges. Running as root (USER root in Dockerfile) sets +# HOME=/root; leaving it unchanged causes uv to try /root/.cache/uv, which +# uid 1000 cannot write to. +try: + pw = pwd.getpwuid(1000) + home = pw.pw_dir + user = pw.pw_name +except KeyError: + home = "/app" + user = "langflow" + +# Drop from root to langflow (uid=1000, gid=1000). +os.setgid(1000) +os.setuid(1000) + +# Restore environment variables to reflect the unprivileged user. +os.environ["HOME"] = home +os.environ["USER"] = user + +os.execvp(sys.argv[1], sys.argv[1:]) diff --git a/src/tui/main.py b/src/tui/main.py index 3ab8e45ff..884ce864f 100644 --- a/src/tui/main.py +++ b/src/tui/main.py @@ -64,17 +64,17 @@ class OpenRAGTUI(App): min-width: 20; border: solid #3f3f46; } - + #config-header { text-align: center; margin-bottom: 2; } - + #config-scroll { height: 1fr; overflow-y: auto; } - + #config-form { width: 80%; max-width: 100; @@ -82,7 +82,7 @@ class OpenRAGTUI(App): padding: 1; height: auto; } - + #config-form Input { margin-bottom: 1; width: 100%; @@ -99,7 +99,7 @@ class OpenRAGTUI(App): width: auto; min-width: 12; } - + #config-form Label { margin-bottom: 0; padding-left: 1; @@ -145,18 +145,18 @@ class OpenRAGTUI(App): } /* Docs path actions row */ - + #services-content { height: 100%; } - + #runtime-status { background: $panel; border: solid $primary; padding: 1; margin-bottom: 1; } - + #services-table { height: auto; max-height: 12; @@ -169,52 +169,52 @@ class OpenRAGTUI(App): margin-bottom: 1; } - - + + #logs-scroll { height: 1fr; border: solid $primary; background: $surface; } - + .controls-row { align: left middle; height: auto; margin: 1 0; } - + .controls-row > * { margin-right: 1; } - + .label { width: auto; margin-right: 1; text-style: bold; } - + #system-info { background: $panel; border: solid $primary; padding: 2; height: 1fr; } - + TabbedContent { height: 1fr; } - + TabPane { padding: 1; height: 1fr; } - + .tab-header { text-style: bold; color: $accent; margin-bottom: 1; } - + TabPane ScrollableContainer { height: 100%; padding: 1; @@ -367,7 +367,7 @@ def __init__(self): self.container_manager = ContainerManager() self.env_manager = EnvManager() self.docling_manager = DoclingManager() # Initialize singleton instance - + def notify( self, message: str, @@ -457,17 +457,17 @@ def _copy_assets(resource_tree, destination: Path, allowed_suffixes: Optional[It def copy_sample_documents(*, force: bool = False) -> None: """Copy sample documents from package to host directory. - + Uses the first path from OPENRAG_DOCUMENTS_PATHS env var. Defaults to ~/.openrag/documents if not configured. """ from .managers.env_manager import EnvManager from pathlib import Path - + # Get the configured documents path from env env_manager = EnvManager() env_manager.load_existing_env() - + # Parse the first path from the documents paths config documents_path_str = env_manager.config.openrag_documents_paths if documents_path_str: @@ -478,7 +478,7 @@ def copy_sample_documents(*, force: bool = False) -> None: else: # Default fallback documents_dir = Path.home() / ".openrag" / "documents" - + documents_dir.mkdir(parents=True, exist_ok=True) try: @@ -504,11 +504,11 @@ def copy_sample_documents(*, force: bool = False) -> None: def copy_sample_flows(*, force: bool = False) -> None: """Copy sample flows from package to host directory. - + Flows are placed in ~/.openrag/flows/ which will be volume-mounted to containers. """ from pathlib import Path - + # Flows always go to ~/.openrag/flows/ - this will be volume-mounted flows_dir = Path.home() / ".openrag" / "flows" flows_dir.mkdir(parents=True, exist_ok=True) @@ -524,7 +524,7 @@ def copy_sample_flows(*, force: bool = False) -> None: def copy_compose_files(*, force: bool = False) -> None: """Copy docker-compose templates into the TUI workspace if they are missing.""" from utils.paths import get_tui_compose_file - + try: assets_root = files("tui._assets") except Exception as e: @@ -534,7 +534,7 @@ def copy_compose_files(*, force: bool = False) -> None: for filename in ("docker-compose.yml", "docker-compose.gpu.yml"): is_gpu = "gpu" in filename destination = get_tui_compose_file(gpu=is_gpu) - + if destination.exists() and not force: continue @@ -562,7 +562,7 @@ def migrate_legacy_data_directories(): """Migrate data from CWD-based directories to ~/.openrag/. This is a one-time migration for users upgrading from the old layout. - Migrates: documents, flows, keys, config, opensearch-data + Migrates: documents, flows, keys, config, opensearch-data, langflow-data Prompts user for confirmation before migrating. If user declines, exits with a message to downgrade to v1.52 or earlier. @@ -585,6 +585,7 @@ def migrate_legacy_data_directories(): (cwd / "keys", target_base / "keys", "keys"), (cwd / "config", target_base / "config", "config"), (cwd / "opensearch-data", target_base / "data" / "opensearch-data", "OpenSearch data"), + (cwd / "langflow-data", target_base / "data" / "langflow-data", "Langflow data"), ] # Check which sources exist and need migration @@ -608,7 +609,8 @@ def migrate_legacy_data_directories(): env_manager.config.openrag_config_path = f"{home}/.openrag/config" env_manager.config.openrag_data_path = f"{home}/.openrag/data" env_manager.config.opensearch_data_path = f"{home}/.openrag/data/opensearch-data" - env_manager.save_env() + env_manager.config.langflow_data_path = f"{home}/.openrag/data/langflow-data" + env_manager.save_env_file() logger.info("Updated .env file with centralized paths") except Exception as e: logger.warning(f"Failed to update .env paths: {e}") @@ -687,7 +689,8 @@ def migrate_legacy_data_directories(): env_manager.config.openrag_config_path = f"{home}/.openrag/config" env_manager.config.openrag_data_path = f"{home}/.openrag/data" env_manager.config.opensearch_data_path = f"{home}/.openrag/data/opensearch-data" - env_manager.save_env() + env_manager.config.langflow_data_path = f"{home}/.openrag/data/langflow-data" + env_manager.save_env_file() print(" Updated .env with centralized paths") logger.info("Updated .env file with centralized paths") except Exception as e: @@ -753,6 +756,7 @@ def setup_host_directories(): - ~/.openrag/config/ (for configuration) - ~/.openrag/data/ (for backend data: conversations, OAuth tokens, etc.) - ~/.openrag/data/opensearch-data/ (for OpenSearch index) + - LANGFLOW_DATA_PATH (for Langflow database and state) """ base_dir = Path.home() / ".openrag" directories = [ @@ -768,10 +772,59 @@ def setup_host_directories(): directory.mkdir(parents=True, exist_ok=True) logger.debug(f"Ensured directory exists: {directory}") + # Resolve the configured LANGFLOW_DATA_PATH so we pre-create the exact + # directory that Docker/Podman will mount, regardless of user customisation. + langflow_data_dir = _resolve_langflow_data_path(base_dir) + langflow_data_dir.mkdir(parents=True, exist_ok=True) + logger.debug(f"Ensured directory exists: {langflow_data_dir}") + + # langflow-data must be world-writable so the Langflow container user (uid 1000) + # can write into it on macOS where Podman's :U uid-remapping does not reliably + # update host directory ownership through the VM layer. + os.chmod(langflow_data_dir, 0o777) + # Generate JWT keys on host to avoid container permission issues generate_jwt_keys(base_dir / "keys") +def _resolve_langflow_data_path(base_dir: Path) -> Path: + """Return the absolute path for the Langflow data directory. + + Reads LANGFLOW_DATA_PATH from the TUI .env file when available; falls back + to the default location (~/.openrag/data/langflow-data). + + Relative paths are not valid in the TUI context because the process working + directory is unpredictable. If a relative path is found the default is used + and a warning is logged so the user can correct their configuration. + """ + default = base_dir / "data" / "langflow-data" + try: + from .managers.env_manager import EnvManager + env_manager = EnvManager() + env_manager.load_existing_env() + raw = env_manager.config.langflow_data_path + if not raw: + return default + expanded = raw.replace("$HOME", str(Path.home())) + resolved = Path(expanded).expanduser() + if not resolved.is_absolute(): + logger.warning( + f"LANGFLOW_DATA_PATH='{raw}' is a relative path, which is not supported " + f"in the TUI. Resetting to default: {default}" + ) + try: + env_manager.config.langflow_data_path = str(default) + env_manager.save_env_file() + logger.info(f"Updated LANGFLOW_DATA_PATH to {default} in {env_manager.env_file}") + except Exception as write_err: + logger.error(f"Could not update LANGFLOW_DATA_PATH in env file: {write_err}") + return default + return resolved + except Exception as e: + logger.error(f"Could not read LANGFLOW_DATA_PATH from env, using default: {e}") + return default + + def _run_tui_app(): """Run the existing Textual TUI application.""" app = None diff --git a/src/tui/screens/monitor.py b/src/tui/screens/monitor.py index b39bf91f1..5c2b6fd81 100644 --- a/src/tui/screens/monitor.py +++ b/src/tui/screens/monitor.py @@ -538,25 +538,28 @@ def expand_path(path_str: str) -> Path: async def _factory_reset_with_data_clear(self) -> AsyncIterator[tuple[bool, str]]: """Generator that stops services and clears opensearch data.""" + import shutil + # First stop all services async for success, message in self.container_manager.reset_services(): yield success, message if not success and "failed" in message.lower(): return - - # Now clear opensearch-data using container - yield False, "Clearing OpenSearch data..." - # Get opensearch data path from env config + + # Get data paths from env config from ..managers.env_manager import EnvManager env_manager = EnvManager() env_manager.load_existing_env() + + # Clear opensearch-data using container + yield False, "Clearing OpenSearch data..." opensearch_data_path = Path(env_manager.config.opensearch_data_path.replace("$HOME", str(Path.home()))).expanduser() if opensearch_data_path.exists(): async for success, message in self.container_manager.clear_opensearch_data_volume(): yield success, message if not success and "failed" in message.lower(): return - + # Recreate empty opensearch-data directory try: opensearch_data_path.mkdir(parents=True, exist_ok=True) @@ -564,7 +567,23 @@ async def _factory_reset_with_data_clear(self) -> AsyncIterator[tuple[bool, str] except Exception as e: yield False, f"Error recreating opensearch-data directory: {e}" return - + + # Delete langflow-data directory (mirrors Makefile factory-reset behaviour) + yield False, "Clearing Langflow data..." + from tui.main import _resolve_langflow_data_path + langflow_data_path = _resolve_langflow_data_path(Path.home() / ".openrag").resolve() + home = Path.home().resolve() + if not str(langflow_data_path).startswith(str(home) + "/"): + yield False, f"Refusing to delete path outside home directory: {langflow_data_path}" + return + if langflow_data_path.exists(): + try: + shutil.rmtree(langflow_data_path) + yield True, "Langflow data directory removed" + except Exception as e: + yield False, f"Error removing langflow-data directory: {e}" + return + yield True, "Factory reset completed successfully" async def _prune_images(self) -> None: