From 08a5949f9e3fe5052ad6c7ba5e9eca8ed721bf53 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Mon, 17 Aug 2026 21:09:44 -0400 Subject: [PATCH 1/4] Add RocksDB action with embedded and remote connection modes Adds a new `rocksdb` action type so agents can query an ordered RocksDB key-value store, with its own configuration card and Test Connection button in the action modal. RocksDB is an embedded library rather than a network database server, so the action supports two connection modes: - Embedded: opens a RocksDB directory on the application host through the `rocksdict` binding, using read-only or secondary access. - Remote: calls a RocksDB-backed HTTP/JSON service with optional bearer or API-key authentication. Reads are always available (get_value, get_values, key_exists, scan_prefix, scan_range, list_column_families, get_database_stats). Writes (put_value, delete_value, write_batch) are refused unless the action explicitly allows them, matching the read-only default of the SQL action. Security: - Embedded paths are gated by a new ROCKSDB_ALLOWED_ROOTS environment variable and validated with realpath plus a common-prefix check, so traversal, symlink escapes, and name-prefix siblings are rejected. Embedded mode stays disabled while the variable is unset. - The test-connection route requires an authenticated user and returns sanitized errors that never expose filesystem layout or driver text. - Remote base URLs are restricted to http and https, and service failures report the HTTP status code only. The `rocksdict` wheel is imported lazily, so an image without it still starts, still lists the action type, and still supports remote mode. Validation: 8/8 RocksDB functional tests including a real embedded RocksDB round trip, 12/12 route policy tests, 4/4 Cosmos regression tests, and a Playwright DOM pass over the action modal with no console errors. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 6 +- application/single_app/requirements.txt | 1 + .../single_app/route_backend_plugins.py | 292 ++++ .../plugin_health_checker.py | 95 +- .../semantic_kernel_plugins/rocksdb_plugin.py | 1272 +++++++++++++++++ .../static/js/plugin_modal_stepper.js | 501 ++++++- .../static/js/workspace/view-utils.js | 1 + .../json/schemas/rocksdb.definition.json | 7 + .../single_app/templates/_plugin_modal.html | 267 ++++ docs/explanation/features/index.md | 1 + .../features/v0.250.209/ROCKSDB_ACTION.md | 264 ++++ docs/explanation/release_notes.md | 12 + docs/reference/logging-tags.md | 1 + functional_tests/test_rocksdb_plugin.py | 764 ++++++++++ .../test_workspace_rocksdb_action_modal.py | 264 ++++ 15 files changed, 3742 insertions(+), 6 deletions(-) create mode 100644 application/single_app/semantic_kernel_plugins/rocksdb_plugin.py create mode 100644 application/single_app/static/json/schemas/rocksdb.definition.json create mode 100644 docs/explanation/features/v0.250.209/ROCKSDB_ACTION.md create mode 100644 functional_tests/test_rocksdb_plugin.py create mode 100644 ui_tests/test_workspace_rocksdb_action_modal.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 4ce4fc421..855f7d5fa 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -96,9 +96,13 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.250.208" +VERSION = "0.250.209" IS_DEVELOPMENT = is_development_env_enabled() +# Directories that embedded RocksDB actions are allowed to open, separated by os.pathsep. +# Embedded RocksDB connections stay disabled while this is empty. +ROCKSDB_ALLOWED_ROOTS = os.getenv('ROCKSDB_ALLOWED_ROOTS', '') + SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') SESSION_COOKIE_HTTPONLY = os.getenv('SESSION_COOKIE_HTTPONLY', 'true').lower() != 'false' SESSION_COOKIE_SECURE = os.getenv('SESSION_COOKIE_SECURE', 'false').lower() == 'true' diff --git a/application/single_app/requirements.txt b/application/single_app/requirements.txt index 9a7d74f29..6f06f977b 100644 --- a/application/single_app/requirements.txt +++ b/application/single_app/requirements.txt @@ -58,6 +58,7 @@ pyodbc==5.3.0 PyMySQL==1.1.2 azure-monitor-opentelemetry==1.6.13 psycopg2-binary==2.9.10 +rocksdict==0.3.29 cython==3.2.4 pyyaml==6.0.2 aiohttp==3.14.3 diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index b480315d3..c564db8b5 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -17,12 +17,28 @@ build_sql_server_odbc_connection_string, connect_with_sql_server_odbc_fallback, ) +from semantic_kernel_plugins.rocksdb_plugin import ( + ACCESS_MODE_SECONDARY, + AUTH_SCHEME_API_KEY, + AUTH_SCHEME_BEARER, + AUTH_SCHEME_NONE, + CONNECTION_MODE_EMBEDDED, + CONNECTION_MODE_REMOTE, + DEFAULT_API_KEY_HEADER, + SUPPORTED_ACCESS_MODES, + SUPPORTED_AUTH_SCHEMES, + SUPPORTED_CONNECTION_MODES, + import_rocksdict, + normalize_rocksdb_base_url, + resolve_allowed_rocksdb_path, +) from functions_settings import get_settings, is_tabular_processing_enabled, update_settings from functions_authentication import * from functions_appinsights import log_event from swagger_wrapper import swagger_route, get_auth_security import logging import os +import requests from functions_debug import debug_print import importlib.util from functions_plugins import get_merged_plugin_settings @@ -411,6 +427,24 @@ def get_plugin_types(allowed_type_filter=None): }, 'metadata': {'description': 'Example Cosmos query plugin'} } + elif 'rocksdb' in module_name.lower(): + safe_manifest = { + 'endpoint': 'https://rocksdb.example.com/api', + 'auth': {'type': 'NoAuth'}, + 'additionalFields': { + 'connection_mode': CONNECTION_MODE_REMOTE, + 'base_url': 'https://rocksdb.example.com/api', + 'auth_scheme': AUTH_SCHEME_NONE, + 'column_family': 'default', + 'key_encoding': 'utf8', + 'value_encoding': 'utf8', + 'read_only': True, + 'max_results': 100, + 'max_value_bytes': 32768, + 'timeout': 30, + }, + 'metadata': {'description': 'Example RocksDB key-value action'} + } elif 'blob_storage' in module_name.lower(): safe_manifest = { 'endpoint': 'https://example.blob.core.windows.net', @@ -2482,3 +2516,261 @@ def capture_response_headers(response_headers, _): 'success': False, 'error': 'Cosmos DB authentication failed or the account could not be reached. Verify the endpoint and the selected authentication settings.' }), 400 + + +@bpap.route('/api/plugins/test-rocksdb-connection', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +def test_rocksdb_connection(): + """Test a RocksDB action connection in either embedded or remote HTTP mode.""" + data = request.get_json(silent=True) or {} + user_id = get_current_user_id() + connection_mode = (data.get('connection_mode') or CONNECTION_MODE_EMBEDDED).strip().lower() + timeout = min(max(int(data.get('timeout', 10) or 10), 1), 30) + + if connection_mode not in SUPPORTED_CONNECTION_MODES: + return jsonify({'success': False, 'error': "Connection mode must be either 'embedded' or 'remote'."}), 400 + + try: + existing_plugin = _load_existing_plugin_for_test(data.get('existing_plugin'), user_id) + except PermissionError as exc: + return jsonify({'success': False, 'error': str(exc)}), 403 + except LookupError as exc: + return jsonify({'success': False, 'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'success': False, 'error': str(exc)}), 400 + + if connection_mode == CONNECTION_MODE_EMBEDDED: + return _test_rocksdb_embedded_connection(data, user_id) + + return _test_rocksdb_remote_connection(data, user_id, existing_plugin, timeout) + + +def _test_rocksdb_embedded_connection(data, user_id): + """Open a local RocksDB directory read-only and confirm it is readable.""" + db_path = (data.get('db_path') or '').strip() + access_mode = (data.get('access_mode') or 'read_only').strip().lower() + secondary_path = (data.get('secondary_path') or '').strip() + column_family = (data.get('column_family') or 'default').strip() or 'default' + + if access_mode not in SUPPORTED_ACCESS_MODES: + return jsonify({'success': False, 'error': "Access mode must be either 'read_only' or 'secondary'."}), 400 + if access_mode == ACCESS_MODE_SECONDARY and not secondary_path: + return jsonify({'success': False, 'error': 'A secondary path is required when access mode is secondary.'}), 400 + + try: + resolved_path = resolve_allowed_rocksdb_path(db_path) + except ValueError as exc: + return jsonify({'success': False, 'error': str(exc)}), 400 + + database = None + try: + rocksdict = import_rocksdict() + except RuntimeError as exc: + return jsonify({'success': False, 'error': str(exc)}), 400 + + try: + options = rocksdict.Options(raw_mode=True) + column_families = list(rocksdict.Rdict.list_cf(resolved_path, options)) + + if column_family not in column_families: + return jsonify({ + 'success': False, + 'error': f"Column family '{column_family}' was not found. Available column families: {', '.join(column_families) or 'none'}." + }), 404 + + if access_mode == ACCESS_MODE_SECONDARY: + access_type = rocksdict.AccessType.secondary(secondary_path) + else: + access_type = rocksdict.AccessType.read_only(False) + + database = rocksdict.Rdict(resolved_path, options=options, access_type=access_type) + if access_mode == ACCESS_MODE_SECONDARY: + database.try_catch_up_with_primary() + + target = database.get_column_family(column_family) if column_family != 'default' else database + iterator = target.iter() + iterator.seek_to_first() + has_data = iterator.valid() + + try: + estimated_keys = target.property_value('rocksdb.estimate-num-keys') + except Exception: + estimated_keys = None + + log_event( + '[PLUGINS] RocksDB embedded connection test succeeded', + extra={ + 'user_id': user_id, + 'access_mode': access_mode, + 'column_family': column_family, + 'column_family_count': len(column_families), + 'has_data': has_data, + }, + level=logging.INFO, + ) + return jsonify({ + 'success': True, + 'message': ( + f"Opened the RocksDB database in {access_mode} mode. " + f"Column families: {', '.join(column_families)}. " + f"Estimated keys in '{column_family}': {estimated_keys if estimated_keys is not None else 'unknown'}." + ), + 'column_families': column_families, + }) + except Exception as exc: + log_event( + f'[PLUGINS] RocksDB embedded connection test failed: {exc}', + extra={'user_id': user_id, 'access_mode': access_mode, 'column_family': column_family}, + level=logging.WARNING, + exceptionTraceback=True, + ) + return jsonify({ + 'success': False, + 'error': _sanitize_rocksdb_error(exc, access_mode) + }), 400 + finally: + if database is not None: + try: + database.close() + except Exception: + pass + + +def _test_rocksdb_remote_connection(data, user_id, existing_plugin, timeout): + """Probe a remote RocksDB HTTP service and confirm it answers with the expected contract.""" + auth_scheme = (data.get('auth_scheme') or AUTH_SCHEME_NONE).strip().lower() + api_key_header = (data.get('api_key_header') or '').strip() or DEFAULT_API_KEY_HEADER + verify_tls = str(data.get('verify_tls', True)).strip().lower() not in ('false', '0', 'no', 'off') + auth_key = (data.get('auth_key') or '').strip() + + try: + base_url = normalize_rocksdb_base_url(data.get('base_url') or '') + except ValueError as exc: + return jsonify({'success': False, 'error': str(exc)}), 400 + + if not base_url: + return jsonify({'success': False, 'error': 'A RocksDB service base URL is required.'}), 400 + if auth_scheme not in SUPPORTED_AUTH_SCHEMES: + return jsonify({'success': False, 'error': "Auth scheme must be one of 'none', 'bearer', or 'api_key'."}), 400 + + if auth_scheme != AUTH_SCHEME_NONE: + existing_auth = {} + if isinstance(existing_plugin, dict) and isinstance(existing_plugin.get('auth'), dict): + existing_auth = existing_plugin['auth'] + + if auth_key == ui_trigger_word: + auth_key = existing_auth.get('key', '') + if auth_key == ui_trigger_word: + return jsonify({ + 'success': False, + 'error': 'The stored RocksDB service token could not be resolved for testing. Re-enter the token.' + }), 400 + + try: + auth_key = _resolve_secret_value_for_plugin_test(auth_key, 'auth.key', plugin_label='RocksDB') + except ValueError as exc: + return jsonify({'success': False, 'error': str(exc)}), 400 + + if not auth_key: + return jsonify({ + 'success': False, + 'error': 'A service token is required when the auth scheme is bearer or api_key.' + }), 400 + + headers = {'Accept': 'application/json'} + if auth_scheme == AUTH_SCHEME_BEARER: + headers['Authorization'] = f'Bearer {auth_key}' + elif auth_scheme == AUTH_SCHEME_API_KEY: + headers[api_key_header] = auth_key + + try: + response = requests.get( + f'{base_url}/health', + headers=headers, + timeout=timeout, + verify=verify_tls, + ) + + if response.status_code == 404: + # Minimal services may not implement /health, so fall back to a single-item scan. + response = requests.post( + f'{base_url}/scan', + json={'limit': 1}, + headers=headers, + timeout=timeout, + verify=verify_tls, + ) + + if response.status_code in (401, 403): + return jsonify({ + 'success': False, + 'error': 'The RocksDB service rejected the supplied credentials. Verify the auth scheme and token.' + }), 403 + + if response.status_code >= 400: + return jsonify({ + 'success': False, + 'error': f'The RocksDB service returned HTTP {response.status_code}.' + }), 400 + + try: + response.json() + except ValueError: + return jsonify({ + 'success': False, + 'error': 'The RocksDB service returned a non-JSON response. Confirm the base URL points at the service API root.' + }), 400 + + log_event( + '[PLUGINS] RocksDB remote connection test succeeded', + extra={'user_id': user_id, 'auth_scheme': auth_scheme, 'status_code': response.status_code}, + level=logging.INFO, + ) + return jsonify({ + 'success': True, + 'message': f'Successfully reached the RocksDB service at {base_url}.' + }) + except requests.exceptions.SSLError: + return jsonify({ + 'success': False, + 'error': 'TLS verification failed for the RocksDB service. Check the certificate or disable TLS verification for trusted internal hosts.' + }), 400 + except requests.exceptions.Timeout: + return jsonify({ + 'success': False, + 'error': f'The RocksDB service did not respond within {timeout} seconds.' + }), 400 + except Exception as exc: + log_event( + f'[PLUGINS] RocksDB remote connection test failed: {exc}', + extra={'user_id': user_id, 'auth_scheme': auth_scheme}, + level=logging.WARNING, + exceptionTraceback=True, + ) + return jsonify({ + 'success': False, + 'error': 'The RocksDB service could not be reached. Verify the base URL and that the service is running.' + }), 400 + + +def _sanitize_rocksdb_error(error, access_mode): + """Return a safe, actionable message for embedded RocksDB failures.""" + error_text = str(error) + lowered_text = error_text.lower() + + if 'corrupt' in lowered_text: + return 'The RocksDB database at the configured path appears to be corrupted or is not a RocksDB directory.' + if any(marker in lowered_text for marker in ('lock hold', 'lock file', 'resource temporarily unavailable', 'already held')): + return ( + 'The RocksDB database is locked by another process. Use secondary access mode to read a ' + 'database that has a live writer.' + ) + if 'no such file' in lowered_text or 'does not exist' in lowered_text or 'not found' in lowered_text: + return 'No RocksDB database was found at the configured path.' + if 'permission' in lowered_text or 'access is denied' in lowered_text: + return 'The application does not have permission to read the configured RocksDB directory.' + if access_mode == ACCESS_MODE_SECONDARY: + return 'The RocksDB database could not be opened in secondary mode. Verify the database path and the secondary path.' + return 'The RocksDB database could not be opened in read-only mode. Verify the database path and file permissions.' diff --git a/application/single_app/semantic_kernel_plugins/plugin_health_checker.py b/application/single_app/semantic_kernel_plugins/plugin_health_checker.py index fcb12109a..048a15a43 100644 --- a/application/single_app/semantic_kernel_plugins/plugin_health_checker.py +++ b/application/single_app/semantic_kernel_plugins/plugin_health_checker.py @@ -58,6 +58,22 @@ validate_mcp_endpoint_for_transport, ) from functions_simplechat_operations import SIMPLECHAT_DEFAULT_ENDPOINT +from semantic_kernel_plugins.rocksdb_plugin import ( + CONNECTION_MODE_EMBEDDED, + CONNECTION_MODE_REMOTE, + ACCESS_MODE_SECONDARY, + AUTH_SCHEME_NONE, + MAX_RESULTS_CEILING, + MAX_TIMEOUT, + MAX_VALUE_BYTES_CEILING, + ROCKSDB_PLUGIN_TYPE, + SUPPORTED_ACCESS_MODES, + SUPPORTED_AUTH_SCHEMES, + SUPPORTED_CONNECTION_MODES, + SUPPORTED_KEY_ENCODINGS, + SUPPORTED_VALUE_ENCODINGS, + normalize_rocksdb_base_url, +) class PluginHealthChecker: @@ -281,7 +297,10 @@ def validate_plugin_manifest(manifest: Dict[str, Any], plugin_type: str) -> Tupl errors.append("Cosmos plugin only supports auth.type values 'identity' and 'key'") if auth_type == 'key' and not auth.get('key'): errors.append("Cosmos plugin requires auth.key when auth.type='key'") - + + elif plugin_type == ROCKSDB_PLUGIN_TYPE: + errors.extend(PluginHealthChecker._validate_rocksdb_manifest(manifest)) + elif plugin_type == 'log_analytics': additional_fields = manifest.get('additionalFields', {}) if 'workspaceId' not in additional_fields: @@ -376,7 +395,79 @@ def validate_plugin_manifest(manifest: Dict[str, Any], plugin_type: str) -> Tupl errors.append("Azure Maps plugin requires auth.key with an Azure Maps subscription key") return len(errors) == 0, errors - + + @staticmethod + def _validate_rocksdb_manifest(manifest: Dict[str, Any]) -> List[str]: + """Validate a RocksDB action manifest for both embedded and remote connection modes.""" + errors: List[str] = [] + additional_fields = manifest.get('additionalFields', {}) + if not isinstance(additional_fields, dict): + additional_fields = {} + + auth = manifest.get('auth', {}) if isinstance(manifest.get('auth'), dict) else {} + connection_mode = str(additional_fields.get('connection_mode') or CONNECTION_MODE_EMBEDDED).strip().lower() + read_only = str(additional_fields.get('read_only', True)).strip().lower() not in ('false', '0', 'no', 'off') + + if connection_mode not in SUPPORTED_CONNECTION_MODES: + errors.append("RocksDB plugin connection_mode must be either 'embedded' or 'remote'") + + key_encoding = str(additional_fields.get('key_encoding') or 'utf8').strip().lower() + if key_encoding not in SUPPORTED_KEY_ENCODINGS: + errors.append("RocksDB plugin key_encoding must be either 'utf8' or 'base64'") + + value_encoding = str(additional_fields.get('value_encoding') or 'utf8').strip().lower() + if value_encoding not in SUPPORTED_VALUE_ENCODINGS: + errors.append("RocksDB plugin value_encoding must be one of 'utf8', 'base64', or 'json'") + + for field_name, minimum, maximum in ( + ('max_results', 1, MAX_RESULTS_CEILING), + ('max_value_bytes', 1, MAX_VALUE_BYTES_CEILING), + ('timeout', 1, MAX_TIMEOUT), + ): + raw_value = additional_fields.get(field_name) + if raw_value in (None, ''): + continue + try: + parsed_value = int(raw_value) + except (TypeError, ValueError): + errors.append(f"RocksDB plugin additionalFields.{field_name} must be an integer") + continue + if parsed_value < minimum or parsed_value > maximum: + errors.append( + f"RocksDB plugin additionalFields.{field_name} must be between {minimum} and {maximum}" + ) + + if connection_mode == CONNECTION_MODE_EMBEDDED: + if not str(additional_fields.get('db_path') or '').strip(): + errors.append("RocksDB plugin requires 'db_path' in additionalFields for embedded connections") + + access_mode = str(additional_fields.get('access_mode') or 'read_only').strip().lower() + if access_mode not in SUPPORTED_ACCESS_MODES: + errors.append("RocksDB plugin access_mode must be either 'read_only' or 'secondary'") + elif access_mode == ACCESS_MODE_SECONDARY: + if not read_only: + errors.append("RocksDB plugin access_mode 'secondary' requires read-only access") + if not str(additional_fields.get('secondary_path') or '').strip(): + errors.append("RocksDB plugin requires 'secondary_path' when access_mode is 'secondary'") + + elif connection_mode == CONNECTION_MODE_REMOTE: + base_url = str(additional_fields.get('base_url') or manifest.get('endpoint') or '').strip() + if not base_url: + errors.append("RocksDB plugin requires 'base_url' in additionalFields for remote connections") + else: + try: + normalize_rocksdb_base_url(base_url) + except ValueError as validation_error: + errors.append(f"RocksDB plugin base_url is invalid: {validation_error}") + + auth_scheme = str(additional_fields.get('auth_scheme') or AUTH_SCHEME_NONE).strip().lower() + if auth_scheme not in SUPPORTED_AUTH_SCHEMES: + errors.append("RocksDB plugin auth_scheme must be one of 'none', 'bearer', or 'api_key'") + elif auth_scheme != AUTH_SCHEME_NONE and not auth.get('key'): + errors.append("RocksDB plugin requires auth.key when auth_scheme is 'bearer' or 'api_key'") + + return errors + @staticmethod def check_plugin_health(plugin_instance: BasePlugin, plugin_name: str) -> Dict[str, Any]: """ diff --git a/application/single_app/semantic_kernel_plugins/rocksdb_plugin.py b/application/single_app/semantic_kernel_plugins/rocksdb_plugin.py new file mode 100644 index 000000000..8f4f2edc0 --- /dev/null +++ b/application/single_app/semantic_kernel_plugins/rocksdb_plugin.py @@ -0,0 +1,1272 @@ +# rocksdb_plugin.py +""" +RocksDB key-value store plugin for Semantic Kernel. + +Supports two connection modes: + +* ``embedded`` - opens a RocksDB database directory on the application host through the + ``rocksdict`` binding. Access is restricted to directories listed in the + ``ROCKSDB_ALLOWED_ROOTS`` environment variable. +* ``remote`` - calls a RocksDB-backed HTTP/JSON service using the SimpleChat RocksDB service + contract documented in ``docs/explanation/features/ROCKSDB_ACTION.md``. + +Read operations are always available. Write operations are refused unless the action is +explicitly configured with ``read_only`` disabled. + +The ``rocksdict`` package is imported lazily inside the embedded code path so that action +discovery, action listing, and application startup keep working when the native wheel is not +installed in the current image. +""" + +import base64 +import json +import logging +import os +import threading +from typing import Any, Dict, List, Optional, Tuple, Union +from urllib.parse import urlparse + +import requests +from semantic_kernel.functions import kernel_function + +from functions_appinsights import log_event +from semantic_kernel_plugins.base_plugin import BasePlugin +from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger + + +ROCKSDB_PLUGIN_TYPE = "rocksdb" +ROCKSDB_ALLOWED_ROOTS_ENV = "ROCKSDB_ALLOWED_ROOTS" + +CONNECTION_MODE_EMBEDDED = "embedded" +CONNECTION_MODE_REMOTE = "remote" +SUPPORTED_CONNECTION_MODES = (CONNECTION_MODE_EMBEDDED, CONNECTION_MODE_REMOTE) + +ACCESS_MODE_READ_ONLY = "read_only" +ACCESS_MODE_SECONDARY = "secondary" +SUPPORTED_ACCESS_MODES = (ACCESS_MODE_READ_ONLY, ACCESS_MODE_SECONDARY) + +AUTH_SCHEME_NONE = "none" +AUTH_SCHEME_BEARER = "bearer" +AUTH_SCHEME_API_KEY = "api_key" +SUPPORTED_AUTH_SCHEMES = (AUTH_SCHEME_NONE, AUTH_SCHEME_BEARER, AUTH_SCHEME_API_KEY) + +KEY_ENCODING_UTF8 = "utf8" +KEY_ENCODING_BASE64 = "base64" +SUPPORTED_KEY_ENCODINGS = (KEY_ENCODING_UTF8, KEY_ENCODING_BASE64) + +VALUE_ENCODING_UTF8 = "utf8" +VALUE_ENCODING_BASE64 = "base64" +VALUE_ENCODING_JSON = "json" +SUPPORTED_VALUE_ENCODINGS = (VALUE_ENCODING_UTF8, VALUE_ENCODING_BASE64, VALUE_ENCODING_JSON) + +DEFAULT_COLUMN_FAMILY = "default" +DEFAULT_API_KEY_HEADER = "X-API-Key" +DEFAULT_MAX_RESULTS = 100 +MAX_RESULTS_CEILING = 1000 +DEFAULT_MAX_VALUE_BYTES = 32768 +MAX_VALUE_BYTES_CEILING = 1048576 +DEFAULT_TIMEOUT = 30 +MAX_TIMEOUT = 300 +ALLOWED_REMOTE_SCHEMES = ("http", "https") + +EMBEDDED_STAT_PROPERTIES = ( + "rocksdb.estimate-num-keys", + "rocksdb.estimate-live-data-size", + "rocksdb.total-sst-files-size", + "rocksdb.num-live-versions", +) + + +def get_allowed_rocksdb_roots() -> List[str]: + """Return the absolute directories embedded RocksDB actions are allowed to open.""" + raw_value = os.getenv(ROCKSDB_ALLOWED_ROOTS_ENV, "") or "" + allowed_roots = [] + for candidate in raw_value.split(os.pathsep): + cleaned_candidate = candidate.strip().strip('"').strip("'") + if not cleaned_candidate: + continue + allowed_roots.append(os.path.realpath(os.path.expanduser(cleaned_candidate))) + return allowed_roots + + +def resolve_allowed_rocksdb_path(db_path: str) -> str: + """Resolve an embedded RocksDB path and confirm it sits inside the operator allowlist.""" + cleaned_path = (db_path or "").strip() + if not cleaned_path: + raise ValueError("A RocksDB database path is required for embedded connections.") + + allowed_roots = get_allowed_rocksdb_roots() + if not allowed_roots: + raise ValueError( + "Embedded RocksDB connections are disabled because the ROCKSDB_ALLOWED_ROOTS " + "environment variable is not configured. Set it to the directories that RocksDB " + "actions may open." + ) + + resolved_path = os.path.realpath(os.path.expanduser(cleaned_path)) + for allowed_root in allowed_roots: + try: + common_path = os.path.commonpath([resolved_path, allowed_root]) + except ValueError: + # Paths on different Windows drives cannot share a common prefix. + continue + if os.path.normcase(common_path) == os.path.normcase(allowed_root): + return resolved_path + + raise ValueError( + "The configured RocksDB path is outside every directory allowed by " + "ROCKSDB_ALLOWED_ROOTS." + ) + + +def normalize_rocksdb_base_url(base_url: str) -> str: + """Normalize a remote RocksDB service base URL and reject unsupported schemes.""" + cleaned_url = (base_url or "").strip().rstrip("/") + if not cleaned_url: + return "" + + parsed_url = urlparse(cleaned_url) + if parsed_url.scheme.lower() not in ALLOWED_REMOTE_SCHEMES: + raise ValueError("RocksDB service URLs must use the http or https scheme.") + if not parsed_url.netloc: + raise ValueError("RocksDB service URLs must include a host name.") + return cleaned_url + + +def import_rocksdict(): + """Import ``rocksdict`` lazily and raise an actionable error when it is unavailable.""" + try: + import rocksdict + except ImportError as import_error: + raise RuntimeError( + "Embedded RocksDB connections require the 'rocksdict' package. Install it with " + "'pip install rocksdict' and restart the application." + ) from import_error + return rocksdict + + +class RocksDbResult: + """Structured RocksDB plugin result paired with the plugin metadata.""" + + def __init__(self, data: Any, metadata: Dict[str, Any]): + self.data = data + self.metadata = metadata + + def __str__(self) -> str: + return str(self.data) + + def __repr__(self) -> str: + return f"RocksDbResult(data={self.data!r}, metadata={self.metadata!r})" + + +class RocksDbPlugin(BasePlugin): + """RocksDB key-value plugin supporting embedded and remote HTTP connections.""" + + _embedded_handle_cache: Dict[str, Any] = {} + _embedded_cache_lock = threading.Lock() + + def __init__(self, manifest: Optional[Dict[str, Any]] = None): + super().__init__(manifest) + self.manifest = manifest or {} + additional_fields = self.manifest.get("additionalFields", {}) or {} + auth = self.manifest.get("auth", {}) or {} + + self.connection_mode = self._normalize_choice( + additional_fields.get("connection_mode"), CONNECTION_MODE_EMBEDDED + ) + self.db_path = str(additional_fields.get("db_path") or "").strip() + self.access_mode = self._normalize_choice( + additional_fields.get("access_mode"), ACCESS_MODE_READ_ONLY + ) + self.secondary_path = str(additional_fields.get("secondary_path") or "").strip() + self.column_family = ( + str(additional_fields.get("column_family") or "").strip() or DEFAULT_COLUMN_FAMILY + ) + + self.base_url = str( + additional_fields.get("base_url") or self.manifest.get("endpoint") or "" + ).strip().rstrip("/") + self.auth_scheme = self._normalize_choice( + additional_fields.get("auth_scheme"), AUTH_SCHEME_NONE + ) + self.api_key_header = ( + str(additional_fields.get("api_key_header") or "").strip() or DEFAULT_API_KEY_HEADER + ) + self.verify_tls = self._coerce_bool(additional_fields.get("verify_tls"), True) + + self.key_encoding = self._normalize_choice( + additional_fields.get("key_encoding"), KEY_ENCODING_UTF8 + ) + self.value_encoding = self._normalize_choice( + additional_fields.get("value_encoding"), VALUE_ENCODING_UTF8 + ) + self.key_prefix_hints = self._normalize_prefix_hints( + additional_fields.get("key_prefix_hints") + ) + self.read_only = self._coerce_bool(additional_fields.get("read_only"), True) + self.max_results = self._coerce_int( + additional_fields.get("max_results"), DEFAULT_MAX_RESULTS, "max_results" + ) + self.max_value_bytes = self._coerce_int( + additional_fields.get("max_value_bytes"), DEFAULT_MAX_VALUE_BYTES, "max_value_bytes" + ) + self.timeout = self._coerce_int(additional_fields.get("timeout"), DEFAULT_TIMEOUT, "timeout") + + self.auth_type = str(auth.get("type") or "NoAuth").strip() + self.auth_key = str(auth.get("key") or "").strip() + self._metadata = self.manifest.get("metadata", {}) or {} + self.resolved_db_path = "" + self._http_session = None + + self._validate_configuration() + + log_event( + "[ROCKSDB_PLUGIN] Initialized plugin", + extra={ + "connection_mode": self.connection_mode, + "access_mode": self.access_mode, + "column_family": self.column_family, + "key_encoding": self.key_encoding, + "value_encoding": self.value_encoding, + "read_only": self.read_only, + "max_results": self.max_results, + "max_value_bytes": self.max_value_bytes, + "timeout": self.timeout, + "auth_scheme": self.auth_scheme, + "has_auth_key": bool(self.auth_key), + "prefix_hint_count": len(self.key_prefix_hints), + }, + level=logging.INFO, + ) + + @property + def display_name(self) -> str: + return "RocksDB" + + @property + def metadata(self) -> Dict[str, Any]: + user_desc = self._metadata.get( + "description", + "RocksDB key-value store plugin for a single configured database.", + ) + write_desc = ( + "Write operations are blocked because this action is configured as read-only." + if self.read_only + else "Write operations are enabled for this action; confirm intent before mutating data." + ) + api_desc = ( + "This plugin reads an ordered RocksDB key-value store. Keys are byte strings sorted " + "lexicographically, so prefix and range scans are the efficient access patterns; " + "avoid scanning the whole keyspace. Use get_value for a single key, get_values for a " + f"batch of keys, and scan_prefix or scan_range to walk a key range. {write_desc}" + ) + return { + "name": self._metadata.get("name", "rocksdb_plugin"), + "type": ROCKSDB_PLUGIN_TYPE, + "description": f"{user_desc}\n\n{api_desc}", + "methods": [ + { + "name": "get_value", + "description": "Read a single value by exact key.", + "parameters": [ + {"name": "key", "type": "str", "description": "Exact key to read.", "required": True}, + {"name": "column_family", "type": "str", "description": "Optional column family override.", "required": False}, + ], + "returns": {"type": "RocksDbResult", "description": "The value plus lookup metadata."}, + }, + { + "name": "get_values", + "description": "Read several values in one call using exact keys.", + "parameters": [ + {"name": "keys", "type": "List[str] | str", "description": "Keys to read, as a list or JSON array string.", "required": True}, + {"name": "column_family", "type": "str", "description": "Optional column family override.", "required": False}, + ], + "returns": {"type": "RocksDbResult", "description": "One result entry per requested key."}, + }, + { + "name": "key_exists", + "description": "Check whether a key is present without returning its value.", + "parameters": [ + {"name": "key", "type": "str", "description": "Exact key to test.", "required": True}, + {"name": "column_family", "type": "str", "description": "Optional column family override.", "required": False}, + ], + "returns": {"type": "RocksDbResult", "description": "Existence flag for the key."}, + }, + { + "name": "scan_prefix", + "description": "List key-value pairs whose keys start with a prefix.", + "parameters": [ + {"name": "prefix", "type": "str", "description": "Key prefix to scan.", "required": True}, + {"name": "limit", "type": "int", "description": "Optional per-call result cap.", "required": False}, + {"name": "column_family", "type": "str", "description": "Optional column family override.", "required": False}, + ], + "returns": {"type": "RocksDbResult", "description": "Matching key-value pairs and truncation state."}, + }, + { + "name": "scan_range", + "description": "List key-value pairs between an inclusive start key and an exclusive end key.", + "parameters": [ + {"name": "start_key", "type": "str", "description": "Inclusive first key of the range.", "required": False}, + {"name": "end_key", "type": "str", "description": "Exclusive last key of the range.", "required": False}, + {"name": "limit", "type": "int", "description": "Optional per-call result cap.", "required": False}, + {"name": "reverse", "type": "bool", "description": "Walk the range in descending key order.", "required": False}, + {"name": "column_family", "type": "str", "description": "Optional column family override.", "required": False}, + ], + "returns": {"type": "RocksDbResult", "description": "Matching key-value pairs and truncation state."}, + }, + { + "name": "list_column_families", + "description": "List the column families available in the configured database.", + "parameters": [], + "returns": {"type": "RocksDbResult", "description": "Available column family names."}, + }, + { + "name": "get_database_stats", + "description": "Report size and key-count estimates for the configured database.", + "parameters": [], + "returns": {"type": "RocksDbResult", "description": "Database statistics."}, + }, + { + "name": "put_value", + "description": "Write a single key-value pair. Requires the action to allow writes.", + "parameters": [ + {"name": "key", "type": "str", "description": "Key to write.", "required": True}, + {"name": "value", "type": "Any", "description": "Value to store.", "required": True}, + {"name": "column_family", "type": "str", "description": "Optional column family override.", "required": False}, + ], + "returns": {"type": "RocksDbResult", "description": "Write confirmation."}, + }, + { + "name": "delete_value", + "description": "Delete a single key. Requires the action to allow writes.", + "parameters": [ + {"name": "key", "type": "str", "description": "Key to delete.", "required": True}, + {"name": "column_family", "type": "str", "description": "Optional column family override.", "required": False}, + ], + "returns": {"type": "RocksDbResult", "description": "Delete confirmation."}, + }, + { + "name": "write_batch", + "description": "Apply several put and delete operations atomically. Requires the action to allow writes.", + "parameters": [ + {"name": "operations", "type": "List[Dict[str, Any]] | str", "description": "Operations as a list or JSON array string, each with op, key, and value.", "required": True}, + {"name": "column_family", "type": "str", "description": "Optional column family override.", "required": False}, + ], + "returns": {"type": "RocksDbResult", "description": "Batch write confirmation."}, + }, + ], + } + + def get_functions(self) -> List[str]: + return [ + "get_value", + "get_values", + "key_exists", + "scan_prefix", + "scan_range", + "list_column_families", + "get_database_stats", + "put_value", + "delete_value", + "write_batch", + ] + + def set_http_session(self, session: Any) -> None: + """Override the HTTP session used for remote RocksDB service calls.""" + self._http_session = session + + def build_instruction_context(self) -> str: + """Return agent instruction text describing the configured RocksDB store.""" + hint_lines = [f"- {prefix_hint}" for prefix_hint in self.key_prefix_hints] or [ + "- No key prefix hints were configured." + ] + target = self.base_url if self.connection_mode == CONNECTION_MODE_REMOTE else self.db_path + access_line = ( + f"- Access: read-only ({self.access_mode})" + if self.read_only + else "- Access: reads and writes are enabled" + ) + return ( + f"### RocksDB Store: {target}\n" + f"- Connection mode: {self.connection_mode}\n" + f"- Column family: {self.column_family}\n" + f"{access_line}\n" + f"- Max results per call: {self.max_results}\n" + f"- Key encoding: {self.key_encoding}, value encoding: {self.value_encoding}\n" + "- Keys are byte strings sorted lexicographically; prefer prefix and range scans.\n" + "- Configured key prefix hints:\n" + + "\n".join(hint_lines) + ) + + @kernel_function(description="Read a single RocksDB value by its exact key. Use this when you already know the full key; use scan_prefix when you only know how the key starts.") + @plugin_function_logger("RocksDbPlugin") + def get_value(self, key: str, column_family: Optional[str] = None) -> RocksDbResult: + try: + resolved_family = self._resolve_column_family(column_family) + if self.connection_mode == CONNECTION_MODE_REMOTE: + payload = self._remote_request( + "POST", "/get", {"key": key, "column_family": resolved_family} + ) + found = bool(payload.get("found", payload.get("value") is not None)) + value, truncated, byte_length = self._normalize_remote_value(payload.get("value")) + else: + database = self._get_embedded_database(resolved_family) + raw_value = database.get(self._encode_key(key)) + found = raw_value is not None + value, truncated, byte_length = self._decode_embedded_value(raw_value) + + return self._success( + { + "key": key, + "column_family": resolved_family, + "found": found, + "value": value if found else None, + "value_bytes": byte_length, + "value_truncated": truncated, + } + ) + except Exception as exc: + return self._failure("get_value", exc, {"key": key}) + + @kernel_function(description="Read several RocksDB values in one call using their exact keys. Pass the keys as a list or a JSON array string.") + @plugin_function_logger("RocksDbPlugin") + def get_values( + self, + keys: Union[str, List[str]], + column_family: Optional[str] = None, + ) -> RocksDbResult: + try: + resolved_family = self._resolve_column_family(column_family) + normalized_keys = self._normalize_key_list(keys) + if not normalized_keys: + raise ValueError("At least one key is required.") + if len(normalized_keys) > self.max_results: + normalized_keys = normalized_keys[: self.max_results] + + results = [] + if self.connection_mode == CONNECTION_MODE_REMOTE: + payload = self._remote_request( + "POST", + "/multi_get", + {"keys": normalized_keys, "column_family": resolved_family}, + ) + remote_results = payload.get("results") + if not isinstance(remote_results, list): + raise RuntimeError("RocksDB service returned an unexpected multi_get payload.") + for entry in remote_results: + entry_dict = entry if isinstance(entry, dict) else {} + found = bool(entry_dict.get("found", entry_dict.get("value") is not None)) + value, truncated, byte_length = self._normalize_remote_value( + entry_dict.get("value") + ) + results.append( + { + "key": entry_dict.get("key"), + "found": found, + "value": value if found else None, + "value_bytes": byte_length, + "value_truncated": truncated, + } + ) + else: + database = self._get_embedded_database(resolved_family) + for single_key in normalized_keys: + raw_value = database.get(self._encode_key(single_key)) + found = raw_value is not None + value, truncated, byte_length = self._decode_embedded_value(raw_value) + results.append( + { + "key": single_key, + "found": found, + "value": value if found else None, + "value_bytes": byte_length, + "value_truncated": truncated, + } + ) + + return self._success( + { + "column_family": resolved_family, + "requested_key_count": len(normalized_keys), + "found_count": sum(1 for entry in results if entry.get("found")), + "results": results, + } + ) + except Exception as exc: + return self._failure("get_values", exc, {"key_count": self._safe_length(keys)}) + + @kernel_function(description="Check whether a RocksDB key exists without returning its value. Use this for cheap existence checks on large values.") + @plugin_function_logger("RocksDbPlugin") + def key_exists(self, key: str, column_family: Optional[str] = None) -> RocksDbResult: + try: + resolved_family = self._resolve_column_family(column_family) + if self.connection_mode == CONNECTION_MODE_REMOTE: + payload = self._remote_request( + "POST", "/exists", {"key": key, "column_family": resolved_family} + ) + exists = bool(payload.get("exists", payload.get("found", False))) + else: + database = self._get_embedded_database(resolved_family) + exists = database.get(self._encode_key(key)) is not None + + return self._success( + {"key": key, "column_family": resolved_family, "exists": exists} + ) + except Exception as exc: + return self._failure("key_exists", exc, {"key": key}) + + @kernel_function(description="List RocksDB key-value pairs whose keys start with a prefix. This is the efficient way to explore a RocksDB store because keys are stored in sorted order.") + @plugin_function_logger("RocksDbPlugin") + def scan_prefix( + self, + prefix: str, + limit: Optional[int] = None, + column_family: Optional[str] = None, + ) -> RocksDbResult: + try: + resolved_family = self._resolve_column_family(column_family) + effective_limit = self._resolve_limit(limit) + items, truncated = self._scan( + column_family=resolved_family, + prefix=prefix, + start_key=None, + end_key=None, + limit=effective_limit, + reverse=False, + ) + return self._success( + { + "column_family": resolved_family, + "prefix": prefix, + "limit": effective_limit, + "item_count": len(items), + "is_truncated": truncated, + "items": items, + } + ) + except Exception as exc: + return self._failure("scan_prefix", exc, {"prefix": prefix}) + + @kernel_function(description="List RocksDB key-value pairs between an inclusive start key and an exclusive end key. Set reverse to walk the range from the highest key downwards.") + @plugin_function_logger("RocksDbPlugin") + def scan_range( + self, + start_key: Optional[str] = None, + end_key: Optional[str] = None, + limit: Optional[int] = None, + reverse: bool = False, + column_family: Optional[str] = None, + ) -> RocksDbResult: + try: + resolved_family = self._resolve_column_family(column_family) + effective_limit = self._resolve_limit(limit) + items, truncated = self._scan( + column_family=resolved_family, + prefix=None, + start_key=start_key, + end_key=end_key, + limit=effective_limit, + reverse=self._coerce_bool(reverse, False), + ) + return self._success( + { + "column_family": resolved_family, + "start_key": start_key, + "end_key": end_key, + "reverse": self._coerce_bool(reverse, False), + "limit": effective_limit, + "item_count": len(items), + "is_truncated": truncated, + "items": items, + } + ) + except Exception as exc: + return self._failure( + "scan_range", exc, {"start_key": start_key, "end_key": end_key} + ) + + @kernel_function(description="List the column families available in the configured RocksDB database.") + @plugin_function_logger("RocksDbPlugin") + def list_column_families(self) -> RocksDbResult: + try: + if self.connection_mode == CONNECTION_MODE_REMOTE: + payload = self._remote_request("GET", "/column_families") + column_families = payload.get("column_families") + if not isinstance(column_families, list): + raise RuntimeError( + "RocksDB service returned an unexpected column_families payload." + ) + else: + rocksdict = import_rocksdict() + column_families = list( + rocksdict.Rdict.list_cf( + self.resolved_db_path, rocksdict.Options(raw_mode=True) + ) + ) + + return self._success( + { + "column_families": column_families, + "configured_column_family": self.column_family, + } + ) + except Exception as exc: + return self._failure("list_column_families", exc) + + @kernel_function(description="Report size and key-count estimates for the configured RocksDB database.") + @plugin_function_logger("RocksDbPlugin") + def get_database_stats(self) -> RocksDbResult: + try: + if self.connection_mode == CONNECTION_MODE_REMOTE: + payload = self._remote_request("GET", "/stats") + statistics = payload.get("stats", payload) + else: + database = self._get_embedded_database(self.column_family) + statistics = {} + for property_name in EMBEDDED_STAT_PROPERTIES: + try: + statistics[property_name] = database.property_value(property_name) + except Exception: + statistics[property_name] = None + + return self._success( + { + "connection_mode": self.connection_mode, + "column_family": self.column_family, + "read_only": self.read_only, + "stats": statistics, + } + ) + except Exception as exc: + return self._failure("get_database_stats", exc) + + @kernel_function(description="Write a single RocksDB key-value pair. This is refused when the action is configured as read-only.") + @plugin_function_logger("RocksDbPlugin") + def put_value( + self, + key: str, + value: Any, + column_family: Optional[str] = None, + ) -> RocksDbResult: + blocked_result = self._write_guard("put_value") + if blocked_result is not None: + return blocked_result + + try: + resolved_family = self._resolve_column_family(column_family) + if self.connection_mode == CONNECTION_MODE_REMOTE: + self._remote_request( + "POST", + "/put", + {"key": key, "value": value, "column_family": resolved_family}, + ) + else: + database = self._get_embedded_database(resolved_family) + database[self._encode_key(key)] = self._encode_value(value) + + log_event( + "[ROCKSDB_PLUGIN] Key written", + extra={"connection_mode": self.connection_mode, "column_family": resolved_family}, + level=logging.INFO, + ) + return self._success( + {"key": key, "column_family": resolved_family, "written": True} + ) + except Exception as exc: + return self._failure("put_value", exc, {"key": key}) + + @kernel_function(description="Delete a single RocksDB key. This is refused when the action is configured as read-only.") + @plugin_function_logger("RocksDbPlugin") + def delete_value(self, key: str, column_family: Optional[str] = None) -> RocksDbResult: + blocked_result = self._write_guard("delete_value") + if blocked_result is not None: + return blocked_result + + try: + resolved_family = self._resolve_column_family(column_family) + if self.connection_mode == CONNECTION_MODE_REMOTE: + self._remote_request( + "POST", "/delete", {"key": key, "column_family": resolved_family} + ) + else: + database = self._get_embedded_database(resolved_family) + database.delete(self._encode_key(key)) + + log_event( + "[ROCKSDB_PLUGIN] Key deleted", + extra={"connection_mode": self.connection_mode, "column_family": resolved_family}, + level=logging.INFO, + ) + return self._success( + {"key": key, "column_family": resolved_family, "deleted": True} + ) + except Exception as exc: + return self._failure("delete_value", exc, {"key": key}) + + @kernel_function(description="Apply several RocksDB put and delete operations atomically. Each operation needs an op of put or delete, a key, and a value for puts. This is refused when the action is configured as read-only.") + @plugin_function_logger("RocksDbPlugin") + def write_batch( + self, + operations: Union[str, List[Dict[str, Any]]], + column_family: Optional[str] = None, + ) -> RocksDbResult: + blocked_result = self._write_guard("write_batch") + if blocked_result is not None: + return blocked_result + + try: + resolved_family = self._resolve_column_family(column_family) + normalized_operations = self._normalize_batch_operations(operations) + if not normalized_operations: + raise ValueError("At least one batch operation is required.") + if len(normalized_operations) > self.max_results: + raise ValueError( + f"Batch operations are limited to {self.max_results} entries per call." + ) + + if self.connection_mode == CONNECTION_MODE_REMOTE: + self._remote_request( + "POST", + "/batch", + {"operations": normalized_operations, "column_family": resolved_family}, + ) + else: + self._apply_embedded_batch(normalized_operations, resolved_family) + + log_event( + "[ROCKSDB_PLUGIN] Batch applied", + extra={ + "connection_mode": self.connection_mode, + "column_family": resolved_family, + "operation_count": len(normalized_operations), + }, + level=logging.INFO, + ) + return self._success( + { + "column_family": resolved_family, + "operation_count": len(normalized_operations), + "applied": True, + } + ) + except Exception as exc: + return self._failure("write_batch", exc) + + def _validate_configuration(self) -> None: + if self.connection_mode not in SUPPORTED_CONNECTION_MODES: + raise ValueError( + "RocksDbPlugin connection_mode must be either 'embedded' or 'remote'." + ) + if self.key_encoding not in SUPPORTED_KEY_ENCODINGS: + raise ValueError("RocksDbPlugin key_encoding must be either 'utf8' or 'base64'.") + if self.value_encoding not in SUPPORTED_VALUE_ENCODINGS: + raise ValueError( + "RocksDbPlugin value_encoding must be one of 'utf8', 'base64', or 'json'." + ) + if self.max_results < 1 or self.max_results > MAX_RESULTS_CEILING: + raise ValueError( + f"RocksDbPlugin max_results must be between 1 and {MAX_RESULTS_CEILING}." + ) + if self.max_value_bytes < 1 or self.max_value_bytes > MAX_VALUE_BYTES_CEILING: + raise ValueError( + f"RocksDbPlugin max_value_bytes must be between 1 and {MAX_VALUE_BYTES_CEILING}." + ) + if self.timeout < 1 or self.timeout > MAX_TIMEOUT: + raise ValueError( + f"RocksDbPlugin timeout must be between 1 and {MAX_TIMEOUT} seconds." + ) + + if self.connection_mode == CONNECTION_MODE_EMBEDDED: + if self.access_mode not in SUPPORTED_ACCESS_MODES: + raise ValueError( + "RocksDbPlugin access_mode must be either 'read_only' or 'secondary'." + ) + if self.access_mode == ACCESS_MODE_SECONDARY: + if not self.read_only: + raise ValueError( + "RocksDbPlugin access_mode 'secondary' requires read-only access; " + "disable writes or choose a different access mode." + ) + if not self.secondary_path: + raise ValueError( + "RocksDbPlugin requires secondary_path when access_mode is 'secondary'." + ) + self.resolved_db_path = resolve_allowed_rocksdb_path(self.db_path) + else: + self.base_url = normalize_rocksdb_base_url(self.base_url) + if not self.base_url: + raise ValueError("RocksDbPlugin requires base_url for remote connections.") + if self.auth_scheme not in SUPPORTED_AUTH_SCHEMES: + raise ValueError( + "RocksDbPlugin auth_scheme must be one of 'none', 'bearer', or 'api_key'." + ) + if self.auth_scheme != AUTH_SCHEME_NONE and not self.auth_key: + raise ValueError( + "RocksDbPlugin requires auth.key when auth_scheme is 'bearer' or 'api_key'." + ) + + def _success(self, payload: Dict[str, Any]) -> RocksDbResult: + enriched_payload = dict(payload) + enriched_payload.setdefault("connection_mode", self.connection_mode) + return RocksDbResult(enriched_payload, self.metadata) + + def _failure( + self, + operation: str, + error: Exception, + context: Optional[Dict[str, Any]] = None, + ) -> RocksDbResult: + log_event( + f"[ROCKSDB_PLUGIN] {operation} failed: {error}", + extra={ + "operation": operation, + "connection_mode": self.connection_mode, + "column_family": self.column_family, + }, + level=logging.ERROR, + exceptionTraceback=True, + ) + payload = {"operation": operation, "error": str(error), "connection_mode": self.connection_mode} + if context: + payload.update(context) + return RocksDbResult(payload, self.metadata) + + def _write_guard(self, operation: str) -> Optional[RocksDbResult]: + if not self.read_only: + return None + return RocksDbResult( + { + "operation": operation, + "error": ( + f"The '{operation}' operation is blocked because this RocksDB action is " + "configured as read-only." + ), + "read_only": True, + "connection_mode": self.connection_mode, + }, + self.metadata, + ) + + def _resolve_column_family(self, column_family: Optional[str]) -> str: + candidate = str(column_family or "").strip() + return candidate or self.column_family + + def _resolve_limit(self, limit: Optional[int]) -> int: + if limit in (None, ""): + return self.max_results + try: + requested_limit = int(limit) + except (TypeError, ValueError): + return self.max_results + if requested_limit < 1: + return self.max_results + return min(requested_limit, self.max_results) + + def _scan( + self, + column_family: str, + prefix: Optional[str], + start_key: Optional[str], + end_key: Optional[str], + limit: int, + reverse: bool, + ) -> Tuple[List[Dict[str, Any]], bool]: + if self.connection_mode == CONNECTION_MODE_REMOTE: + request_payload = { + "column_family": column_family, + "limit": limit, + "reverse": reverse, + } + if prefix not in (None, ""): + request_payload["prefix"] = prefix + if start_key not in (None, ""): + request_payload["start_key"] = start_key + if end_key not in (None, ""): + request_payload["end_key"] = end_key + + payload = self._remote_request("POST", "/scan", request_payload) + remote_items = payload.get("items") + if not isinstance(remote_items, list): + raise RuntimeError("RocksDB service returned an unexpected scan payload.") + + items = [] + for entry in remote_items[:limit]: + entry_dict = entry if isinstance(entry, dict) else {} + value, truncated, byte_length = self._normalize_remote_value( + entry_dict.get("value") + ) + items.append( + { + "key": entry_dict.get("key"), + "value": value, + "value_bytes": byte_length, + "value_truncated": truncated, + } + ) + is_truncated = bool(payload.get("truncated", len(remote_items) > limit)) + return items, is_truncated + + database = self._get_embedded_database(column_family) + prefix_bytes = self._encode_key(prefix) if prefix not in (None, "") else None + start_bytes = self._encode_key(start_key) if start_key not in (None, "") else None + end_bytes = self._encode_key(end_key) if end_key not in (None, "") else None + + raw_items, is_truncated = self._iterate_embedded( + database, prefix_bytes, start_bytes, end_bytes, limit, reverse + ) + + items = [] + for key_bytes, value_bytes in raw_items: + value, truncated, byte_length = self._decode_embedded_value(value_bytes) + items.append( + { + "key": self._decode_key(key_bytes), + "value": value, + "value_bytes": byte_length, + "value_truncated": truncated, + } + ) + return items, is_truncated + + def _iterate_embedded( + self, + database: Any, + prefix_bytes: Optional[bytes], + start_bytes: Optional[bytes], + end_bytes: Optional[bytes], + limit: int, + reverse: bool, + ) -> Tuple[List[Tuple[bytes, bytes]], bool]: + collected_items: List[Tuple[bytes, bytes]] = [] + is_truncated = False + iterator = database.iter() + + if reverse: + if end_bytes is not None: + iterator.seek_for_prev(end_bytes) + if iterator.valid() and iterator.key() == end_bytes: + iterator.prev() + elif prefix_bytes: + iterator.seek_for_prev(prefix_bytes + b"\xff") + else: + iterator.seek_to_last() + else: + seek_target = start_bytes if start_bytes is not None else prefix_bytes + if seek_target: + iterator.seek(seek_target) + else: + iterator.seek_to_first() + + while iterator.valid(): + key_bytes = iterator.key() + if prefix_bytes and not key_bytes.startswith(prefix_bytes): + break + if not reverse and end_bytes is not None and key_bytes >= end_bytes: + break + if reverse and start_bytes is not None and key_bytes < start_bytes: + break + if len(collected_items) >= limit: + is_truncated = True + break + + collected_items.append((key_bytes, iterator.value())) + if reverse: + iterator.prev() + else: + iterator.next() + + return collected_items, is_truncated + + def _apply_embedded_batch( + self, operations: List[Dict[str, Any]], column_family: str + ) -> None: + rocksdict = import_rocksdict() + root_database = self._get_embedded_root() + write_batch = rocksdict.WriteBatch(raw_mode=True) + + if column_family and column_family != DEFAULT_COLUMN_FAMILY: + write_batch.set_default_column_family( + root_database.get_column_family_handle(column_family) + ) + + for operation in operations: + operation_name = str(operation.get("op") or "").strip().lower() + key_bytes = self._encode_key(operation.get("key")) + if operation_name == "put": + write_batch.put(key_bytes, self._encode_value(operation.get("value"))) + elif operation_name == "delete": + write_batch.delete(key_bytes) + else: + raise ValueError("Batch operations must use an op of 'put' or 'delete'.") + + root_database.write(write_batch) + + def _get_embedded_root(self) -> Any: + rocksdict = import_rocksdict() + cache_key = "|".join( + [ + self.resolved_db_path, + self.access_mode, + self.secondary_path, + "ro" if self.read_only else "rw", + ] + ) + + with self._embedded_cache_lock: + database = self._embedded_handle_cache.get(cache_key) + if database is None: + database = rocksdict.Rdict( + self.resolved_db_path, + options=rocksdict.Options(raw_mode=True), + access_type=self._build_access_type(rocksdict), + ) + self._embedded_handle_cache[cache_key] = database + + if self.read_only and self.access_mode == ACCESS_MODE_SECONDARY: + try: + database.try_catch_up_with_primary() + except Exception as catch_up_error: + log_event( + f"[ROCKSDB_PLUGIN] Secondary catch-up failed: {catch_up_error}", + extra={"access_mode": self.access_mode}, + level=logging.WARNING, + ) + + return database + + def _get_embedded_database(self, column_family: Optional[str] = None) -> Any: + root_database = self._get_embedded_root() + resolved_family = self._resolve_column_family(column_family) + if resolved_family and resolved_family != DEFAULT_COLUMN_FAMILY: + return root_database.get_column_family(resolved_family) + return root_database + + def _build_access_type(self, rocksdict: Any) -> Any: + if not self.read_only: + return rocksdict.AccessType.read_write() + if self.access_mode == ACCESS_MODE_SECONDARY: + return rocksdict.AccessType.secondary(self.secondary_path) + return rocksdict.AccessType.read_only(False) + + def _remote_request( + self, + method: str, + path: str, + payload: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + session = self._http_session + if session is None: + session = requests.Session() + self._http_session = session + + response = session.request( + method, + f"{self.base_url}{path}", + json=payload if payload is not None else None, + headers=self._build_remote_headers(), + timeout=self.timeout, + verify=self.verify_tls, + ) + + status_code = getattr(response, "status_code", None) + if status_code is None or int(status_code) >= 400: + raise RuntimeError( + f"RocksDB service request to {path} failed with HTTP {status_code}." + ) + + try: + body = response.json() + except Exception as decode_error: + raise RuntimeError( + f"RocksDB service request to {path} returned a non-JSON response." + ) from decode_error + + if not isinstance(body, dict): + raise RuntimeError( + f"RocksDB service request to {path} returned an unexpected payload shape." + ) + return body + + def _build_remote_headers(self) -> Dict[str, str]: + headers = {"Accept": "application/json"} + if self.auth_scheme == AUTH_SCHEME_BEARER and self.auth_key: + headers["Authorization"] = f"Bearer {self.auth_key}" + elif self.auth_scheme == AUTH_SCHEME_API_KEY and self.auth_key: + headers[self.api_key_header] = self.auth_key + return headers + + def _encode_key(self, key: Any) -> bytes: + if isinstance(key, bytes): + return key + text_value = "" if key is None else str(key) + if self.key_encoding == KEY_ENCODING_BASE64: + try: + return base64.b64decode(text_value, validate=True) + except Exception as decode_error: + raise ValueError( + "Keys must be valid base64 when key_encoding is 'base64'." + ) from decode_error + return text_value.encode("utf-8") + + def _decode_key(self, raw_key: Optional[bytes]) -> Optional[str]: + if raw_key is None: + return None + if self.key_encoding == KEY_ENCODING_BASE64: + return base64.b64encode(raw_key).decode("ascii") + return raw_key.decode("utf-8", errors="replace") + + def _encode_value(self, value: Any) -> bytes: + if isinstance(value, bytes): + return value + if self.value_encoding == VALUE_ENCODING_BASE64 and isinstance(value, str): + try: + return base64.b64decode(value, validate=True) + except Exception as decode_error: + raise ValueError( + "Values must be valid base64 when value_encoding is 'base64'." + ) from decode_error + if self.value_encoding == VALUE_ENCODING_JSON and not isinstance(value, str): + return json.dumps(value, default=str).encode("utf-8") + return ("" if value is None else str(value)).encode("utf-8") + + def _decode_embedded_value( + self, raw_value: Optional[bytes] + ) -> Tuple[Any, bool, int]: + if raw_value is None: + return None, False, 0 + + byte_length = len(raw_value) + is_truncated = byte_length > self.max_value_bytes + payload = raw_value[: self.max_value_bytes] if is_truncated else raw_value + + if self.value_encoding == VALUE_ENCODING_BASE64: + return base64.b64encode(payload).decode("ascii"), is_truncated, byte_length + + text_value = payload.decode("utf-8", errors="replace") + if self.value_encoding == VALUE_ENCODING_JSON and not is_truncated: + try: + return json.loads(text_value), is_truncated, byte_length + except (TypeError, ValueError): + return text_value, is_truncated, byte_length + return text_value, is_truncated, byte_length + + def _normalize_remote_value(self, value: Any) -> Tuple[Any, bool, int]: + if value is None: + return None, False, 0 + + if isinstance(value, str): + raw_bytes = value.encode("utf-8", errors="replace") + else: + raw_bytes = json.dumps(value, default=str).encode("utf-8") + + byte_length = len(raw_bytes) + if byte_length <= self.max_value_bytes: + return value, False, byte_length + return ( + raw_bytes[: self.max_value_bytes].decode("utf-8", errors="replace"), + True, + byte_length, + ) + + def _normalize_key_list(self, keys: Union[str, List[Any]]) -> List[str]: + candidate_keys = keys + if isinstance(candidate_keys, str): + stripped_keys = candidate_keys.strip() + if stripped_keys.startswith("["): + try: + candidate_keys = json.loads(stripped_keys) + except (TypeError, ValueError) as decode_error: + raise ValueError( + "Keys must be a list or a valid JSON array string." + ) from decode_error + else: + candidate_keys = [ + part.strip() for part in stripped_keys.split(",") if part.strip() + ] + + if not isinstance(candidate_keys, (list, tuple)): + raise ValueError("Keys must be a list or a valid JSON array string.") + return [str(single_key) for single_key in candidate_keys if str(single_key).strip()] + + def _normalize_batch_operations( + self, operations: Union[str, List[Dict[str, Any]]] + ) -> List[Dict[str, Any]]: + candidate_operations = operations + if isinstance(candidate_operations, str): + try: + candidate_operations = json.loads(candidate_operations) + except (TypeError, ValueError) as decode_error: + raise ValueError( + "Batch operations must be a list or a valid JSON array string." + ) from decode_error + + if isinstance(candidate_operations, dict): + candidate_operations = [candidate_operations] + if not isinstance(candidate_operations, (list, tuple)): + raise ValueError("Batch operations must be a list or a valid JSON array string.") + + normalized_operations = [] + for operation in candidate_operations: + if not isinstance(operation, dict): + raise ValueError("Each batch operation must be an object.") + operation_name = str(operation.get("op") or "").strip().lower() + if operation_name not in ("put", "delete"): + raise ValueError("Batch operations must use an op of 'put' or 'delete'.") + if str(operation.get("key") or "").strip() == "": + raise ValueError("Each batch operation requires a key.") + normalized_operations.append( + { + "op": operation_name, + "key": operation.get("key"), + "value": operation.get("value"), + } + ) + return normalized_operations + + @staticmethod + def _normalize_choice(value: Any, default_value: str) -> str: + normalized_value = str(value or "").strip().lower() + return normalized_value or default_value + + @staticmethod + def _normalize_prefix_hints(prefix_hints: Any) -> List[str]: + if prefix_hints in (None, ""): + return [] + if isinstance(prefix_hints, str): + candidates = [part.strip() for part in prefix_hints.replace("\n", ",").split(",")] + elif isinstance(prefix_hints, (list, tuple)): + candidates = [str(part).strip() for part in prefix_hints] + else: + return [] + return [candidate for candidate in candidates if candidate] + + @staticmethod + def _coerce_bool(value: Any, default_value: bool) -> bool: + if value in (None, ""): + return default_value + if isinstance(value, bool): + return value + normalized_value = str(value).strip().lower() + if normalized_value in ("true", "1", "yes", "on"): + return True + if normalized_value in ("false", "0", "no", "off"): + return False + return default_value + + @staticmethod + def _coerce_int(value: Any, default_value: int, field_name: str) -> int: + if value in (None, ""): + return default_value + try: + return int(value) + except (TypeError, ValueError) as convert_error: + raise ValueError( + f"RocksDbPlugin {field_name} must be an integer." + ) from convert_error + + @staticmethod + def _safe_length(value: Any) -> Optional[int]: + try: + return len(value) + except TypeError: + return None diff --git a/application/single_app/static/js/plugin_modal_stepper.js b/application/single_app/static/js/plugin_modal_stepper.js index 3ea7f6509..cfc886fd8 100644 --- a/application/single_app/static/js/plugin_modal_stepper.js +++ b/application/single_app/static/js/plugin_modal_stepper.js @@ -46,6 +46,19 @@ const MCP_RESERVED_CUSTOM_HEADER_NAMES = new Set([ ]); const AZURE_MAPS_PLUGIN_TYPE = 'azure_maps_openlayers'; const AZURE_MAPS_DEFAULT_ENDPOINT = 'https://atlas.microsoft.com'; +const ROCKSDB_PLUGIN_TYPE = 'rocksdb'; +const ROCKSDB_EMBEDDED_ENDPOINT = 'rocksdb://embedded'; +const ROCKSDB_CONNECTION_MODE_EMBEDDED = 'embedded'; +const ROCKSDB_CONNECTION_MODE_REMOTE = 'remote'; +const ROCKSDB_ACCESS_MODE_SECONDARY = 'secondary'; +const ROCKSDB_AUTH_SCHEME_NONE = 'none'; +const ROCKSDB_AUTH_SCHEME_BEARER = 'bearer'; +const ROCKSDB_AUTH_SCHEME_API_KEY = 'api_key'; +const ROCKSDB_DEFAULT_API_KEY_HEADER = 'X-API-Key'; +const ROCKSDB_DEFAULT_COLUMN_FAMILY = 'default'; +const ROCKSDB_DEFAULT_MAX_RESULTS = 100; +const ROCKSDB_DEFAULT_MAX_VALUE_BYTES = 32768; +const ROCKSDB_DEFAULT_TIMEOUT = 30; const CHART_DEFAULT_ENDPOINT = 'chart://internal'; const INTERNAL_DOCUMENT_SEARCH_ENDPOINT = 'internal://document-search'; const MSGRAPH_DEFAULT_ENDPOINT = 'https://graph.microsoft.com'; @@ -697,6 +710,26 @@ export class PluginModalStepper { document.getElementById('sql-identity-select').addEventListener('change', () => this.handleActionIdentityChange('sql')); document.getElementById('cosmos-auth-type').addEventListener('change', () => this.handleCosmosAuthTypeChange()); + const rocksDbModeSelect = document.getElementById('rocksdb-connection-mode'); + if (rocksDbModeSelect) { + rocksDbModeSelect.addEventListener('change', () => this.handleRocksDbConnectionModeChange()); + } + + const rocksDbAccessModeSelect = document.getElementById('rocksdb-access-mode'); + if (rocksDbAccessModeSelect) { + rocksDbAccessModeSelect.addEventListener('change', () => this.handleRocksDbConnectionModeChange()); + } + + const rocksDbReadOnlySelect = document.getElementById('rocksdb-read-only'); + if (rocksDbReadOnlySelect) { + rocksDbReadOnlySelect.addEventListener('change', () => this.handleRocksDbConnectionModeChange()); + } + + const rocksDbAuthSchemeSelect = document.getElementById('rocksdb-auth-scheme'); + if (rocksDbAuthSchemeSelect) { + rocksDbAuthSchemeSelect.addEventListener('change', () => this.handleRocksDbAuthSchemeChange()); + } + // Test SQL connection button const testConnBtn = document.getElementById('sql-test-connection-btn'); if (testConnBtn) { @@ -708,6 +741,11 @@ export class PluginModalStepper { testCosmosBtn.addEventListener('click', () => this.testCosmosConnection()); } + const testRocksDbBtn = document.getElementById('rocksdb-test-connection-btn'); + if (testRocksDbBtn) { + testRocksDbBtn.addEventListener('click', () => this.testRocksDbConnection()); + } + const keyVaultReminderToggle = document.getElementById('plugin-key-vault-reminder-enabled'); if (keyVaultReminderToggle) { keyVaultReminderToggle.addEventListener('change', () => this.toggleKeyVaultReminderFields()); @@ -1234,6 +1272,10 @@ export class PluginModalStepper { return !!(type && type.toLowerCase() === 'cosmos_query'); } + isRocksDbType(type = this.selectedType) { + return !!(type && type.toLowerCase() === ROCKSDB_PLUGIN_TYPE); + } + isDocumentSearchType(type = this.selectedType) { return !!(type && ['search', 'document_search'].includes(type.toLowerCase())); } @@ -3226,7 +3268,7 @@ export class PluginModalStepper { } isStructuredConfigType(type = this.selectedType) { - return this.isSqlType(type) || this.isCosmosType(type) || this.isDocumentSearchType(type) || this.isBlobStorageType(type) || this.isDatabricksType(type) || this.isSnowflakeType(type) || this.isTableauType(type) || this.isMcpType(type) || this.isSimpleChatType(type) || this.isMsGraphType(type) || this.isAzureMapsType(type) || this.isChartType(type); + return this.isSqlType(type) || this.isCosmosType(type) || this.isRocksDbType(type) || this.isDocumentSearchType(type) || this.isBlobStorageType(type) || this.isDatabricksType(type) || this.isSnowflakeType(type) || this.isTableauType(type) || this.isMcpType(type) || this.isSimpleChatType(type) || this.isMsGraphType(type) || this.isAzureMapsType(type) || this.isChartType(type); } showConfigSectionForType() { @@ -3235,6 +3277,7 @@ export class PluginModalStepper { generic: document.getElementById('generic-config-section'), sql: document.getElementById('sql-config-section'), cosmos: document.getElementById('cosmos-config-section'), + rocksdb: document.getElementById('rocksdb-config-section'), documentSearch: document.getElementById('document-search-config-section'), blobStorage: document.getElementById('blob-storage-config-section'), databricks: document.getElementById('databricks-config-section'), @@ -3264,6 +3307,9 @@ export class PluginModalStepper { } else if (this.isCosmosType()) { showOnly('cosmos'); this.initializeCosmosConfiguration(); + } else if (this.isRocksDbType()) { + showOnly('rocksdb'); + this.initializeRocksDbConfiguration(); } else if (this.isDocumentSearchType()) { showOnly('documentSearch'); this.initializeDocumentSearchConfiguration(); @@ -3338,6 +3384,8 @@ export class PluginModalStepper { titleEl.textContent = 'Database Configuration'; } else if (isCosmosType) { titleEl.textContent = 'Cosmos Configuration'; + } else if (this.isRocksDbType()) { + titleEl.textContent = 'RocksDB Configuration'; } else if (isDocumentSearchType) { titleEl.textContent = 'Document Search Configuration'; } else if (isBlobStorageType) { @@ -3533,6 +3581,7 @@ export class PluginModalStepper { const openApiSection = document.getElementById('openapi-config-section'); const sqlSection = document.getElementById('sql-config-section'); const cosmosSection = document.getElementById('cosmos-config-section'); + const rocksDbSection = document.getElementById('rocksdb-config-section'); const documentSearchSection = document.getElementById('document-search-config-section'); const blobStorageSection = document.getElementById('blob-storage-config-section'); const databricksSection = document.getElementById('databricks-config-section'); @@ -3546,6 +3595,7 @@ export class PluginModalStepper { const isOpenApiVisible = !openApiSection.classList.contains('d-none'); const isSqlVisible = !sqlSection.classList.contains('d-none'); const isCosmosVisible = !cosmosSection.classList.contains('d-none'); + const isRocksDbVisible = !!rocksDbSection && !rocksDbSection.classList.contains('d-none'); const isDocumentSearchVisible = !documentSearchSection.classList.contains('d-none'); const isBlobStorageVisible = !blobStorageSection.classList.contains('d-none'); const isDatabricksVisible = !databricksSection.classList.contains('d-none'); @@ -3705,6 +3755,12 @@ export class PluginModalStepper { this.showError('Timeout must be between 1 and 120 seconds.'); return false; } + } else if (isRocksDbVisible) { + const rocksDbError = this.getRocksDbValidationError(); + if (rocksDbError) { + this.showError(rocksDbError); + return false; + } } else if (isBlobStorageVisible) { const connectionString = document.getElementById('blob-storage-connection-string').value.trim(); const containerName = document.getElementById('blob-storage-container-name').value.trim(); @@ -4446,6 +4502,406 @@ export class PluginModalStepper { } } + initializeRocksDbConfiguration() { + const defaultValues = [ + ['rocksdb-connection-mode', ROCKSDB_CONNECTION_MODE_EMBEDDED], + ['rocksdb-access-mode', 'read_only'], + ['rocksdb-auth-scheme', ROCKSDB_AUTH_SCHEME_NONE], + ['rocksdb-verify-tls', 'true'], + ['rocksdb-read-only', 'true'], + ['rocksdb-key-encoding', 'utf8'], + ['rocksdb-value-encoding', 'utf8'], + ['rocksdb-column-family', ROCKSDB_DEFAULT_COLUMN_FAMILY], + ['rocksdb-api-key-header', ROCKSDB_DEFAULT_API_KEY_HEADER], + ['rocksdb-max-results', String(ROCKSDB_DEFAULT_MAX_RESULTS)], + ['rocksdb-max-value-bytes', String(ROCKSDB_DEFAULT_MAX_VALUE_BYTES)], + ['rocksdb-timeout', String(ROCKSDB_DEFAULT_TIMEOUT)] + ]; + + defaultValues.forEach(([fieldId, defaultValue]) => { + const field = document.getElementById(fieldId); + if (field && !field.value) { + field.value = defaultValue; + } + }); + + this.handleRocksDbConnectionModeChange(); + } + + setRocksDbFieldValue(fieldId, value) { + const field = document.getElementById(fieldId); + if (field) { + field.value = value === null || value === undefined ? '' : value; + } + } + + handleRocksDbConnectionModeChange() { + const modeSelect = document.getElementById('rocksdb-connection-mode'); + if (!modeSelect) { + return; + } + + const connectionMode = modeSelect.value || ROCKSDB_CONNECTION_MODE_EMBEDDED; + const isEmbedded = connectionMode !== ROCKSDB_CONNECTION_MODE_REMOTE; + const readOnly = (document.getElementById('rocksdb-read-only')?.value || 'true') === 'true'; + const accessModeSelect = document.getElementById('rocksdb-access-mode'); + + // Secondary access only exists for read-only connections, so drop it when writes are enabled. + if (!readOnly && accessModeSelect && accessModeSelect.value === ROCKSDB_ACCESS_MODE_SECONDARY) { + accessModeSelect.value = 'read_only'; + } + + const isSecondary = !!accessModeSelect && accessModeSelect.value === ROCKSDB_ACCESS_MODE_SECONDARY; + + document.getElementById('rocksdb-embedded-group')?.classList.toggle('d-none', !isEmbedded); + document.getElementById('rocksdb-remote-group')?.classList.toggle('d-none', isEmbedded); + document.getElementById('rocksdb-access-mode-group')?.classList.toggle('d-none', !readOnly); + document.getElementById('rocksdb-secondary-path-group')?.classList.toggle( + 'd-none', + !(isEmbedded && readOnly && isSecondary) + ); + + const embeddedInfoText = document.getElementById('rocksdb-embedded-info-text'); + if (embeddedInfoText) { + embeddedInfoText.textContent = readOnly + ? 'Embedded connections stay disabled until an administrator sets ROCKSDB_ALLOWED_ROOTS to the directories that RocksDB actions may open. Read-only access cannot open a database that a running writer has locked, so choose Secondary for a live database.' + : 'Embedded connections stay disabled until an administrator sets ROCKSDB_ALLOWED_ROOTS to the directories that RocksDB actions may open. Allowing writes opens the database in read-write mode, which needs exclusive access to the directory.'; + } + + this.handleRocksDbAuthSchemeChange(); + + const resultDiv = document.getElementById('rocksdb-test-connection-result'); + if (resultDiv) { + resultDiv.classList.add('d-none'); + } + } + + handleRocksDbAuthSchemeChange() { + const authScheme = document.getElementById('rocksdb-auth-scheme')?.value || ROCKSDB_AUTH_SCHEME_NONE; + const requiresToken = authScheme === ROCKSDB_AUTH_SCHEME_BEARER || authScheme === ROCKSDB_AUTH_SCHEME_API_KEY; + + document.getElementById('rocksdb-auth-key-group')?.classList.toggle('d-none', !requiresToken); + document.getElementById('rocksdb-api-key-header-group')?.classList.toggle( + 'd-none', + authScheme !== ROCKSDB_AUTH_SCHEME_API_KEY + ); + + const keyInput = document.getElementById('rocksdb-auth-key'); + if (keyInput) { + keyInput.required = requiresToken; + } + } + + getRocksDbKeyPrefixHints() { + const rawValue = document.getElementById('rocksdb-key-prefix-hints')?.value || ''; + return rawValue + .split(/[,\n]/) + .map(value => value.trim()) + .filter(Boolean); + } + + isRocksDbRemoteMode() { + return (document.getElementById('rocksdb-connection-mode')?.value || ROCKSDB_CONNECTION_MODE_EMBEDDED) + === ROCKSDB_CONNECTION_MODE_REMOTE; + } + + getRocksDbEndpointValue() { + if (this.isRocksDbRemoteMode()) { + return (document.getElementById('rocksdb-base-url')?.value || '').trim().replace(/\/+$/, ''); + } + return ROCKSDB_EMBEDDED_ENDPOINT; + } + + getRocksDbAuthLabel() { + if (!this.isRocksDbRemoteMode()) { + return 'Local file system access'; + } + + const authScheme = document.getElementById('rocksdb-auth-scheme')?.value || ROCKSDB_AUTH_SCHEME_NONE; + if (authScheme === ROCKSDB_AUTH_SCHEME_BEARER) { + return 'Bearer Token'; + } + if (authScheme === ROCKSDB_AUTH_SCHEME_API_KEY) { + return 'API Key Header'; + } + return 'No Authentication'; + } + + getRocksDbValidationError() { + const readOnly = (document.getElementById('rocksdb-read-only')?.value || 'true') === 'true'; + + if (this.isRocksDbRemoteMode()) { + const baseUrl = (document.getElementById('rocksdb-base-url')?.value || '').trim(); + if (!baseUrl) { + return 'RocksDB service base URL is required for remote connections.'; + } + if (!/^https?:\/\/.+/i.test(baseUrl)) { + return 'RocksDB service base URL must start with http:// or https://.'; + } + + const authScheme = document.getElementById('rocksdb-auth-scheme')?.value || ROCKSDB_AUTH_SCHEME_NONE; + if (authScheme !== ROCKSDB_AUTH_SCHEME_NONE && !(document.getElementById('rocksdb-auth-key')?.value || '').trim()) { + return 'A service token is required when the RocksDB authentication scheme is Bearer Token or API Key Header.'; + } + } else { + if (!(document.getElementById('rocksdb-db-path')?.value || '').trim()) { + return 'RocksDB database path is required for embedded connections.'; + } + + const accessMode = document.getElementById('rocksdb-access-mode')?.value || 'read_only'; + if (readOnly && accessMode === ROCKSDB_ACCESS_MODE_SECONDARY + && !(document.getElementById('rocksdb-secondary-path')?.value || '').trim()) { + return 'Secondary path is required when the RocksDB access mode is Secondary.'; + } + } + + const maxResults = parseInt(document.getElementById('rocksdb-max-results')?.value, 10); + if (Number.isNaN(maxResults) || maxResults < 1 || maxResults > 1000) { + return 'Max results must be between 1 and 1000.'; + } + + const maxValueBytes = parseInt(document.getElementById('rocksdb-max-value-bytes')?.value, 10); + if (Number.isNaN(maxValueBytes) || maxValueBytes < 1 || maxValueBytes > 1048576) { + return 'Max value bytes must be between 1 and 1048576.'; + } + + const timeout = parseInt(document.getElementById('rocksdb-timeout')?.value, 10); + if (Number.isNaN(timeout) || timeout < 1 || timeout > 300) { + return 'Timeout must be between 1 and 300 seconds.'; + } + + return null; + } + + getRocksDbConfiguration() { + const isRemote = this.isRocksDbRemoteMode(); + const readOnly = (document.getElementById('rocksdb-read-only')?.value || 'true') === 'true'; + + const additionalFields = { + connection_mode: isRemote ? ROCKSDB_CONNECTION_MODE_REMOTE : ROCKSDB_CONNECTION_MODE_EMBEDDED, + column_family: (document.getElementById('rocksdb-column-family')?.value || '').trim() || ROCKSDB_DEFAULT_COLUMN_FAMILY, + key_encoding: document.getElementById('rocksdb-key-encoding')?.value || 'utf8', + value_encoding: document.getElementById('rocksdb-value-encoding')?.value || 'utf8', + key_prefix_hints: this.getRocksDbKeyPrefixHints(), + read_only: readOnly, + max_results: parseInt(document.getElementById('rocksdb-max-results')?.value, 10) || ROCKSDB_DEFAULT_MAX_RESULTS, + max_value_bytes: parseInt(document.getElementById('rocksdb-max-value-bytes')?.value, 10) || ROCKSDB_DEFAULT_MAX_VALUE_BYTES, + timeout: parseInt(document.getElementById('rocksdb-timeout')?.value, 10) || ROCKSDB_DEFAULT_TIMEOUT + }; + + const auth = {}; + let endpoint = ROCKSDB_EMBEDDED_ENDPOINT; + + if (isRemote) { + endpoint = (document.getElementById('rocksdb-base-url')?.value || '').trim().replace(/\/+$/, ''); + const authScheme = document.getElementById('rocksdb-auth-scheme')?.value || ROCKSDB_AUTH_SCHEME_NONE; + + additionalFields.base_url = endpoint; + additionalFields.auth_scheme = authScheme; + additionalFields.verify_tls = (document.getElementById('rocksdb-verify-tls')?.value || 'true') === 'true'; + + if (authScheme === ROCKSDB_AUTH_SCHEME_API_KEY) { + additionalFields.api_key_header = (document.getElementById('rocksdb-api-key-header')?.value || '').trim() + || ROCKSDB_DEFAULT_API_KEY_HEADER; + } + + if (authScheme === ROCKSDB_AUTH_SCHEME_NONE) { + auth.type = 'NoAuth'; + } else { + auth.type = 'key'; + auth.key = (document.getElementById('rocksdb-auth-key')?.value || '').trim(); + } + } else { + auth.type = 'NoAuth'; + additionalFields.db_path = (document.getElementById('rocksdb-db-path')?.value || '').trim(); + additionalFields.access_mode = readOnly + ? (document.getElementById('rocksdb-access-mode')?.value || 'read_only') + : 'read_only'; + + if (additionalFields.access_mode === ROCKSDB_ACCESS_MODE_SECONDARY) { + additionalFields.secondary_path = (document.getElementById('rocksdb-secondary-path')?.value || '').trim(); + } + } + + return { endpoint, auth, additionalFields }; + } + + populateRocksDbForm(plugin) { + const additionalFields = plugin.additionalFields || plugin.additional_fields || {}; + const auth = plugin.auth || {}; + const connectionMode = additionalFields.connection_mode || ROCKSDB_CONNECTION_MODE_EMBEDDED; + const isRemote = connectionMode === ROCKSDB_CONNECTION_MODE_REMOTE; + const prefixHints = Array.isArray(additionalFields.key_prefix_hints) + ? additionalFields.key_prefix_hints.join('\n') + : (additionalFields.key_prefix_hints || ''); + + this.setRocksDbFieldValue('rocksdb-connection-mode', connectionMode); + this.setRocksDbFieldValue('rocksdb-db-path', additionalFields.db_path || ''); + this.setRocksDbFieldValue('rocksdb-access-mode', additionalFields.access_mode || 'read_only'); + this.setRocksDbFieldValue('rocksdb-secondary-path', additionalFields.secondary_path || ''); + this.setRocksDbFieldValue('rocksdb-base-url', additionalFields.base_url || (isRemote ? (plugin.endpoint || '') : '')); + this.setRocksDbFieldValue('rocksdb-auth-scheme', additionalFields.auth_scheme || ROCKSDB_AUTH_SCHEME_NONE); + this.setRocksDbFieldValue('rocksdb-auth-key', auth.key || ''); + this.setRocksDbFieldValue('rocksdb-api-key-header', additionalFields.api_key_header || ROCKSDB_DEFAULT_API_KEY_HEADER); + this.setRocksDbFieldValue('rocksdb-verify-tls', additionalFields.verify_tls === false ? 'false' : 'true'); + this.setRocksDbFieldValue('rocksdb-column-family', additionalFields.column_family || ROCKSDB_DEFAULT_COLUMN_FAMILY); + this.setRocksDbFieldValue('rocksdb-read-only', additionalFields.read_only === false ? 'false' : 'true'); + this.setRocksDbFieldValue('rocksdb-key-encoding', additionalFields.key_encoding || 'utf8'); + this.setRocksDbFieldValue('rocksdb-value-encoding', additionalFields.value_encoding || 'utf8'); + this.setRocksDbFieldValue('rocksdb-key-prefix-hints', prefixHints); + this.setRocksDbFieldValue('rocksdb-max-results', additionalFields.max_results || ROCKSDB_DEFAULT_MAX_RESULTS); + this.setRocksDbFieldValue('rocksdb-max-value-bytes', additionalFields.max_value_bytes || ROCKSDB_DEFAULT_MAX_VALUE_BYTES); + this.setRocksDbFieldValue('rocksdb-timeout', additionalFields.timeout || ROCKSDB_DEFAULT_TIMEOUT); + + this.handleRocksDbConnectionModeChange(); + } + + setRocksDbSummaryText(elementId, value) { + const element = document.getElementById(elementId); + if (element) { + element.textContent = value; + } + } + + populateRocksDbSummary() { + const rocksDbSection = document.getElementById('summary-rocksdb-section'); + if (!rocksDbSection) { + return; + } + + if (!this.isRocksDbType()) { + rocksDbSection.classList.add('d-none'); + return; + } + + const isRemote = this.isRocksDbRemoteMode(); + const readOnly = (document.getElementById('rocksdb-read-only')?.value || 'true') === 'true'; + const accessMode = document.getElementById('rocksdb-access-mode')?.value || 'read_only'; + const prefixHints = this.getRocksDbKeyPrefixHints(); + const timeoutValue = (document.getElementById('rocksdb-timeout')?.value || '').trim(); + + let accessLabel = 'Reads and writes'; + if (readOnly) { + accessLabel = !isRemote && accessMode === ROCKSDB_ACCESS_MODE_SECONDARY + ? 'Read-only (secondary)' + : 'Read-only'; + } + + const targetValue = isRemote + ? this.getRocksDbEndpointValue() + : (document.getElementById('rocksdb-db-path')?.value || '').trim(); + + const keyEncoding = document.getElementById('rocksdb-key-encoding')?.value || 'utf8'; + const valueEncoding = document.getElementById('rocksdb-value-encoding')?.value || 'utf8'; + + this.setRocksDbSummaryText('summary-rocksdb-connection-mode', isRemote ? 'Remote HTTP service' : 'Embedded database'); + this.setRocksDbSummaryText('summary-rocksdb-target', targetValue || '-'); + this.setRocksDbSummaryText('summary-rocksdb-access', accessLabel); + this.setRocksDbSummaryText( + 'summary-rocksdb-column-family', + (document.getElementById('rocksdb-column-family')?.value || '').trim() || ROCKSDB_DEFAULT_COLUMN_FAMILY + ); + this.setRocksDbSummaryText('summary-rocksdb-encoding', `Keys: ${keyEncoding}, Values: ${valueEncoding}`); + this.setRocksDbSummaryText( + 'summary-rocksdb-max-results', + (document.getElementById('rocksdb-max-results')?.value || '').trim() || '-' + ); + this.setRocksDbSummaryText( + 'summary-rocksdb-max-value-bytes', + (document.getElementById('rocksdb-max-value-bytes')?.value || '').trim() || '-' + ); + this.setRocksDbSummaryText('summary-rocksdb-timeout', timeoutValue ? `${timeoutValue} seconds` : '-'); + this.setRocksDbSummaryText( + 'summary-rocksdb-key-prefix-hints', + prefixHints.length ? prefixHints.join(', ') : 'None configured' + ); + + rocksDbSection.classList.remove('d-none'); + } + + async testRocksDbConnection() { + const btn = document.getElementById('rocksdb-test-connection-btn'); + const resultDiv = document.getElementById('rocksdb-test-connection-result'); + const alertDiv = document.getElementById('rocksdb-test-connection-alert'); + if (!btn || !resultDiv || !alertDiv) return; + + const validationError = this.getRocksDbValidationError(); + if (validationError) { + resultDiv.classList.remove('d-none'); + alertDiv.className = 'alert alert-warning mb-0 py-2 px-3 small'; + alertDiv.textContent = validationError; + return; + } + + const isRemote = this.isRocksDbRemoteMode(); + const payload = { + connection_mode: isRemote ? ROCKSDB_CONNECTION_MODE_REMOTE : ROCKSDB_CONNECTION_MODE_EMBEDDED, + timeout: Math.min(parseInt(document.getElementById('rocksdb-timeout')?.value, 10) || 10, 30) + }; + + if (isRemote) { + const authScheme = document.getElementById('rocksdb-auth-scheme')?.value || ROCKSDB_AUTH_SCHEME_NONE; + payload.base_url = (document.getElementById('rocksdb-base-url')?.value || '').trim().replace(/\/+$/, ''); + payload.auth_scheme = authScheme; + payload.verify_tls = (document.getElementById('rocksdb-verify-tls')?.value || 'true') === 'true'; + + if (authScheme === ROCKSDB_AUTH_SCHEME_API_KEY) { + payload.api_key_header = (document.getElementById('rocksdb-api-key-header')?.value || '').trim() + || ROCKSDB_DEFAULT_API_KEY_HEADER; + } + if (authScheme !== ROCKSDB_AUTH_SCHEME_NONE) { + payload.auth_key = (document.getElementById('rocksdb-auth-key')?.value || '').trim(); + } + } else { + const readOnly = (document.getElementById('rocksdb-read-only')?.value || 'true') === 'true'; + payload.db_path = (document.getElementById('rocksdb-db-path')?.value || '').trim(); + payload.access_mode = readOnly + ? (document.getElementById('rocksdb-access-mode')?.value || 'read_only') + : 'read_only'; + payload.column_family = (document.getElementById('rocksdb-column-family')?.value || '').trim() + || ROCKSDB_DEFAULT_COLUMN_FAMILY; + + if (payload.access_mode === ROCKSDB_ACCESS_MODE_SECONDARY) { + payload.secondary_path = (document.getElementById('rocksdb-secondary-path')?.value || '').trim(); + } + } + + const existingPluginContext = this.getTestPluginContext(); + if (existingPluginContext) { + payload.existing_plugin = existingPluginContext; + } + + const originalText = btn.innerHTML; + btn.innerHTML = 'Testing...'; + btn.disabled = true; + resultDiv.classList.add('d-none'); + + try { + const response = await fetch('/api/plugins/test-rocksdb-connection', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await response.json(); + + resultDiv.classList.remove('d-none'); + if (data.success) { + alertDiv.className = 'alert alert-success mb-0 py-2 px-3 small'; + alertDiv.innerHTML = '' + this.escapeHtml(data.message || 'Connection successful!'); + } else { + alertDiv.className = 'alert alert-danger mb-0 py-2 px-3 small'; + alertDiv.innerHTML = '' + this.escapeHtml(data.error || 'Connection failed.'); + } + } catch (error) { + resultDiv.classList.remove('d-none'); + alertDiv.className = 'alert alert-danger mb-0 py-2 px-3 small'; + alertDiv.innerHTML = 'Test failed: ' + this.escapeHtml(error.message || 'Network error'); + } finally { + btn.innerHTML = originalText; + btn.disabled = false; + } + } + initializeSqlConfiguration() { // Set default values document.getElementById('sql-read-only').value = 'true'; @@ -5005,6 +5461,8 @@ export class PluginModalStepper { document.getElementById('cosmos-auth-type').value = auth.type || 'identity'; document.getElementById('cosmos-auth-key').value = auth.key || ''; this.initializeCosmosConfiguration(); + } else if (this.isRocksDbType(plugin.type)) { + this.populateRocksDbForm(plugin); } else if (this.isDocumentSearchType(plugin.type)) { this.populateDocumentSearchForm(plugin.additionalFields || {}); this.initializeDocumentSearchConfiguration(); @@ -5078,6 +5536,7 @@ export class PluginModalStepper { const openApiSection = document.getElementById('openapi-config-section'); const sqlSection = document.getElementById('sql-config-section'); const cosmosSection = document.getElementById('cosmos-config-section'); + const rocksDbSection = document.getElementById('rocksdb-config-section'); const documentSearchSection = document.getElementById('document-search-config-section'); const blobStorageSection = document.getElementById('blob-storage-config-section'); const databricksSection = document.getElementById('databricks-config-section'); @@ -5088,6 +5547,7 @@ export class PluginModalStepper { const isOpenApiVisible = !openApiSection.classList.contains('d-none'); const isSqlVisible = !sqlSection.classList.contains('d-none'); const isCosmosVisible = !cosmosSection.classList.contains('d-none'); + const isRocksDbVisible = !!rocksDbSection && !rocksDbSection.classList.contains('d-none'); const isDocumentSearchVisible = !documentSearchSection.classList.contains('d-none'); const isBlobStorageVisible = !blobStorageSection.classList.contains('d-none'); const isDatabricksVisible = !databricksSection.classList.contains('d-none'); @@ -5318,6 +5778,16 @@ export class PluginModalStepper { additionalFields.field_hints = this.getCosmosFieldHints(); additionalFields.max_items = parseInt(document.getElementById('cosmos-max-items').value, 10) || 100; additionalFields.timeout = parseInt(document.getElementById('cosmos-timeout').value, 10) || 30; + } else if (isRocksDbVisible) { + const rocksDbError = this.getRocksDbValidationError(); + if (rocksDbError) { + throw new Error(rocksDbError); + } + + const rocksDbConfig = this.getRocksDbConfiguration(); + endpoint = rocksDbConfig.endpoint; + auth = rocksDbConfig.auth; + additionalFields = rocksDbConfig.additionalFields; } else if (isDocumentSearchVisible) { endpoint = INTERNAL_DOCUMENT_SEARCH_ENDPOINT; auth.type = 'NoAuth'; @@ -5486,6 +5956,7 @@ export class PluginModalStepper { const endpointRow = document.getElementById('summary-plugin-endpoint-row'); const databaseTypeRow = document.getElementById('summary-plugin-database-type-row'); + const isRocksDbType = this.isRocksDbType(); if (isSqlType) { // Hide endpoint for SQL plugins since they don't use endpoints @@ -5498,6 +5969,14 @@ export class PluginModalStepper { endpointRow.style.display = ''; document.getElementById('summary-plugin-database-type').textContent = 'Cosmos DB for NoSQL'; databaseTypeRow.style.display = ''; + } else if (isRocksDbType) { + const endpoint = this.getEndpointValue(); + document.getElementById('summary-plugin-endpoint').textContent = endpoint || '-'; + endpointRow.style.display = ''; + document.getElementById('summary-plugin-database-type').textContent = this.isRocksDbRemoteMode() + ? 'RocksDB HTTP service' + : 'Embedded RocksDB database'; + databaseTypeRow.style.display = ''; } else if (isDocumentSearchType) { endpointRow.style.display = 'none'; document.getElementById('summary-plugin-database-type').textContent = 'Internal document search'; @@ -5568,10 +6047,10 @@ export class PluginModalStepper { } const databaseType = this.getSqlDatabaseType(); - if (!isSqlType && !isCosmosType && !isDocumentSearchType && !isBlobStorageType && !isDatabricksType && !isSnowflakeType && !isTableauType && !isMcpType && !isSimpleChatType && !isMsGraphType && !isAzureMapsType && !isChartType && databaseType) { + if (!isSqlType && !isCosmosType && !isRocksDbType && !isDocumentSearchType && !isBlobStorageType && !isDatabricksType && !isSnowflakeType && !isTableauType && !isMcpType && !isSimpleChatType && !isMsGraphType && !isAzureMapsType && !isChartType && databaseType) { document.getElementById('summary-plugin-database-type').textContent = databaseType; databaseTypeRow.style.display = ''; - } else if (!isSqlType && !isCosmosType && !isDocumentSearchType && !isBlobStorageType && !isDatabricksType && !isSnowflakeType && !isTableauType && !isMcpType && !isSimpleChatType && !isMsGraphType && !isAzureMapsType && !isChartType) { + } else if (!isSqlType && !isCosmosType && !isRocksDbType && !isDocumentSearchType && !isBlobStorageType && !isDatabricksType && !isSnowflakeType && !isTableauType && !isMcpType && !isSimpleChatType && !isMsGraphType && !isAzureMapsType && !isChartType) { databaseTypeRow.style.display = 'none'; } @@ -5579,6 +6058,7 @@ export class PluginModalStepper { this.populateOpenApiSummary(); this.populateSqlSummary(); this.populateCosmosSummary(); + this.populateRocksDbSummary(); this.populateDocumentSearchSummary(); this.populateBlobStorageSummary(); this.populateDatabricksSummary(); @@ -5614,6 +6094,8 @@ export class PluginModalStepper { return document.getElementById('sql-connection-string').value.trim(); } else if (isCosmosType) { return document.getElementById('cosmos-endpoint').value.trim(); + } else if (this.isRocksDbType()) { + return this.getRocksDbEndpointValue(); } else if (isDocumentSearchType) { return INTERNAL_DOCUMENT_SEARCH_ENDPOINT; } else if (isBlobStorageType) { @@ -5664,6 +6146,8 @@ export class PluginModalStepper { } else if (isCosmosType) { const authType = document.getElementById('cosmos-auth-type')?.value || 'identity'; return authType === 'key' ? 'Account Key' : 'Managed Identity'; + } else if (this.isRocksDbType()) { + return this.getRocksDbAuthLabel(); } else if (isDocumentSearchType) { return 'Internal user context'; } else if (isBlobStorageType) { @@ -6231,6 +6715,8 @@ export class PluginModalStepper { currentEndpoint = document.getElementById('sql-connection-string')?.value || ''; } else if (isCosmosType) { currentEndpoint = document.getElementById('cosmos-endpoint')?.value || ''; + } else if (this.isRocksDbType()) { + currentEndpoint = this.getRocksDbEndpointValue(); } else if (isDocumentSearchType) { currentEndpoint = INTERNAL_DOCUMENT_SEARCH_ENDPOINT; } else if (isDatabricksType) { @@ -6273,6 +6759,13 @@ export class PluginModalStepper { if (currentAuthType === 'key') { currentAuthKey = document.getElementById('cosmos-auth-key')?.value || ''; } + } else if (this.isRocksDbType()) { + const rocksDbAuthScheme = document.getElementById('rocksdb-auth-scheme')?.value || ROCKSDB_AUTH_SCHEME_NONE; + const isRemoteMode = (document.getElementById('rocksdb-connection-mode')?.value || ROCKSDB_CONNECTION_MODE_EMBEDDED) === ROCKSDB_CONNECTION_MODE_REMOTE; + currentAuthType = isRemoteMode && rocksDbAuthScheme !== ROCKSDB_AUTH_SCHEME_NONE ? 'key' : 'NoAuth'; + if (currentAuthType === 'key') { + currentAuthKey = document.getElementById('rocksdb-auth-key')?.value || ''; + } } else if (isDocumentSearchType) { currentAuthType = 'NoAuth'; } else if (isTableauType) { @@ -6320,6 +6813,8 @@ export class PluginModalStepper { max_items: parseInt(document.getElementById('cosmos-max-items')?.value, 10) || 100, timeout: parseInt(document.getElementById('cosmos-timeout')?.value, 10) || 30 }, null, 2); + } else if (this.isRocksDbType()) { + currentAdditionalFields = JSON.stringify(this.getRocksDbConfiguration().additionalFields, null, 2); } else if (isDocumentSearchType) { currentAdditionalFields = JSON.stringify(this.getDocumentSearchAdditionalFields(), null, 2); } else if (isTableauType) { diff --git a/application/single_app/static/js/workspace/view-utils.js b/application/single_app/static/js/workspace/view-utils.js index a415b1c7b..00866ff72 100644 --- a/application/single_app/static/js/workspace/view-utils.js +++ b/application/single_app/static/js/workspace/view-utils.js @@ -53,6 +53,7 @@ export function getTypeIcon(type) { const t = type.toLowerCase(); if (t.includes("sql")) return "bi-database"; if (t.includes("cosmos")) return "bi-database-fill-gear"; + if (t.includes("rocksdb")) return "bi-hdd-stack"; if (t.includes("chart")) return "bi-bar-chart-line"; if (t.includes("simplechat")) return "bi-chat-square-dots"; if (t === "mcp" || t.includes("model_context_protocol")) return "bi-diagram-3"; diff --git a/application/single_app/static/json/schemas/rocksdb.definition.json b/application/single_app/static/json/schemas/rocksdb.definition.json new file mode 100644 index 000000000..83dfd04da --- /dev/null +++ b/application/single_app/static/json/schemas/rocksdb.definition.json @@ -0,0 +1,7 @@ +{ + "$schema": "./plugin.definition.schema.json", + "allowedAuthTypes": [ + "NoAuth", + "key" + ] +} diff --git a/application/single_app/templates/_plugin_modal.html b/application/single_app/templates/_plugin_modal.html index f93063be1..411851a4e 100644 --- a/application/single_app/templates/_plugin_modal.html +++ b/application/single_app/templates/_plugin_modal.html @@ -1237,6 +1237,176 @@
API Information
+ + +
+ + +
+ + +
+ + +
RocksDB is an embedded library. Use Embedded to open a database directory on the application host, or Remote to call a RocksDB-backed HTTP service.
+
+
+ +
+ + +
+ + +
Absolute path to the RocksDB directory. The path must sit inside a directory listed in the ROCKSDB_ALLOWED_ROOTS environment variable.
+
+ +
+ + +
Read-only cannot open a database whose lock is held by a running writer. Choose Secondary to follow a live primary database.
+
+ +
+ + +
Writable scratch directory RocksDB uses to track the primary database.
+
+ +
+ Embedded connections stay disabled until an administrator sets ROCKSDB_ALLOWED_ROOTS to the directories that RocksDB actions may open. +
+
+ +
+ + +
+ + +
API root of a service that implements the SimpleChat RocksDB HTTP contract. Only http and https URLs are accepted.
+
+ +
+ + +
+ +
+ + +
If this action already uses a Key Vault-backed token, leave the stored value unchanged to preserve the existing secret.
+
+ +
+ + +
Header used to send the API key. Defaults to X-API-Key.
+
+ +
+ + +
Only disable verification for trusted internal services that use a private certificate authority.
+
+
+ +
+ + +
+
+ + +
Column family used when a call does not specify one.
+
+
+ + +
Allowing writes lets the model put, delete, and batch-write records.
+
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
Optional. Add one key prefix per line so the model knows how the keyspace is organized.
+
+ +
+
+ + +
Hard cap for records returned per call.
+
+
+ + +
Larger values are truncated and flagged.
+
+
+ + +
Absolute timeout for RocksDB service requests.
+
+
+
+ +
+
+ +
+
+ +
+
+
@@ -1586,6 +1756,72 @@
+
+
+
+ RocksDB Configuration +
+
+
+
+
+
+ + - +
+
+
+
+ + - +
+
+
+
+ + - +
+
+
+
+ + - +
+
+
+
+ + - +
+
+
+
+ + - +
+
+
+
+ + - +
+
+
+
+ + - +
+
+
+
+ + - +
+
+
+
+
+