From 7a63a06e66af3d10792192677d34b46ee24674a5 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Tue, 28 Jul 2026 05:44:08 +0200 Subject: [PATCH 01/35] RavenDB-27140 Improve README and cover download failures --- README.md | 226 ++++++++++++++++++++--------------- tests/test_on_demand.py | 118 ++++++++++++++++++ tests/test_startup_errors.py | 36 ++++++ 3 files changed, 286 insertions(+), 94 deletions(-) create mode 100644 tests/test_startup_errors.py diff --git a/README.md b/README.md index 0721020..42e71bd 100644 --- a/README.md +++ b/README.md @@ -1,163 +1,201 @@ # ravendb-embedded -`ravendb-embedded` starts a real RavenDB server as a child process managed by your Python program. -You `pip install` it, start the server, and talk to it with the normal `ravendb` client. There is -no separate server to install, configure, or keep running: the server process's lifetime follows -your Python process. +`ravendb-embedded` starts a real RavenDB server as a child process of your Python application. +The server starts with your code, stops with it, and is accessed through the standard `ravendb` +client. -Reach for it when you want: +Use it for local development, integration tests, or applications that should manage their own +RavenDB process. -- **Local development** without setting up a standalone RavenDB. -- **Integration tests** against a real server instead of a mock (see also `ravendb-test-driver`). -- **Small or self-contained apps** that ship the database alongside the code. +## Install + +```bash +pip install ravendb-embedded +``` + +Python 3.10+ is required. + +## Quick start + +The default mode uses the RavenDB binaries included in the wheel and a compatible system .NET +runtime: ```python -from ravendb_embedded import EmbeddedServer +from ravendb_embedded import EmbeddedServer, ServerOptions + +options = ServerOptions() +options.data_directory = "./data/RavenDB" +options.logs_path = "./logs/RavenDB" with EmbeddedServer() as server: - server.start_server() - with server.get_document_store("Embedded") as store: + server.start_server(options) + with server.get_document_store("MyDatabase") as store: with store.open_session() as session: session.store({"name": "Ayende"}, "people/1") session.save_changes() ``` -## Installation +## Choose how RavenDB runs -```bash -pip install ravendb-embedded -``` +| Mode | System .NET required? | Server lifecycle | Best for | +|------|-----------------------|------------------|----------| +| [Bundled server](#bundled-server-default) | Yes | Managed by the package | The simplest local setup | +| [On-demand self-contained server](#on-demand-self-contained-server) | No | Downloaded, cached, and managed by the package | Portable developer and CI environments | +| [Self-contained server you provide](#self-contained-server-you-provide) | No | Managed by the package | Offline or centrally controlled server artifacts | -The install includes a copy of the RavenDB server binaries. Python 3.10+ is required. +Only the bundled-server mode requires .NET to be installed on the machine running Python. -## The .NET requirement (read this) +### Bundled server (default) -The bundled server is a .NET application, so a matching **.NET runtime** must be on the machine. -The required version tracks the bundled server: +The wheel includes a framework-dependent RavenDB server. Its runtime requirement follows the +RavenDB version: | `ravendb-embedded` version | Required runtime | |----------------------------|------------------| | 7.2.x | .NET 10 | | 7.1.x | .NET 8 | -Check what is installed with `dotnet --list-runtimes` (look for `Microsoft.NETCore.App`). Because -the requirement follows the bundled server, it can change on a minor upgrade, so re-check it when -you bump versions. +Run `dotnet --list-runtimes` and look for `Microsoft.NETCore.App`. Re-check this requirement when +upgrading to a new RavenDB minor version. -If the machine cannot or should not have .NET, use the self-contained path under -[Run without installing .NET](#run-without-installing-net) below. +Runnable walkthrough: [Lab 01 — embedded zero-config](labs/01-embedded-zero-config.md). -## Usage +### On-demand self-contained server -The three sections below are the ways people actually use this package. Pick the one that matches -your environment; each links to a runnable walkthrough in `labs/`. - -### Run it (the default, needs .NET) - -Start the server and get a document store. This is the zero-config path and uses the system .NET -described above. Pass a `ServerOptions` when you want to control where data lives, the bind URL, -and so on. +For the same managed experience without installing .NET, let the package download a +self-contained RavenDB build: ```python from ravendb_embedded import EmbeddedServer, ServerOptions options = ServerOptions() -options.data_directory = "MYPATH/RavenDBDataDir" # optional; defaults to a local RavenDB folder +options.with_auto_downloaded_server() +options.data_directory = "./data/RavenDB" +options.logs_path = "./logs/RavenDB" with EmbeddedServer() as server: server.start_server(options) - with server.get_document_store("MyDb") as store: - ... # ordinary ravendb client code + with server.get_document_store("MyDatabase") as store: + ... ``` -Runnable walkthrough: [`labs/01-embedded-zero-config.md`](labs/01-embedded-zero-config.md). +The operating system and architecture are detected at runtime, so the same Python configuration +is portable across: -### Run without installing .NET +- Windows x64 and x86 +- Linux x64 and ARM64 +- macOS x64 and ARM64 -On locked-down hosts or minimal CI images where you do not want a system .NET, bring a -**self-contained** RavenDB build (it bundles its own runtime). Point the server at the extracted -`Server` folder: the driver detects the bundled runtime and launches the server's native apphost -directly, never calling `dotnet`. +Unsupported targets, including Windows ARM64, fail with an explicit error instead of downloading +an incompatible build. + +The first run downloads a self-contained server (100 MB+) and caches it under +`~/.cache/ravendb-embedded`. Later runs reuse that cache. Pass `cache_root` to +`with_auto_downloaded_server()` when you want a different location: ```python -from ravendb_embedded import EmbeddedServer, ServerOptions +options.with_auto_downloaded_server(cache_root="./.ravendb-cache") +``` -options = ServerOptions() -options.with_external_server("/path/to/extracted/Server") # a self-contained build +By default, the download follows the installed package's RavenDB version line. The cache is not +refreshed automatically; remove that version's cache directory when you intentionally want to +resolve a newer server build from the same line. -with EmbeddedServer() as server: - server.start_server(options) - with server.get_document_store("MyDb") as store: - ... -``` +The wheel does not contain a self-contained build for every platform; it downloads only the build +needed by the current machine. Self-contained mode removes the system .NET requirement, but normal +RavenDB operating-system dependencies still apply. Minimal Linux images may need their +distribution's ICU package. -Download the Server package for your platform from the [RavenDB downloads page](https://ravendb.net/downloads); -the server files live in the archive's `Server/` folder. Runnable walkthrough: -[`labs/02-embedded-external-server.md`](labs/02-embedded-external-server.md). +Runnable walkthrough: [Lab 03 — on-demand server](labs/03-on-demand-server.md). -Or skip the manual download and let the package fetch and cache one for you on first use: +### Self-contained server you provide + +Download and extract the Server package for the target platform, then point the package at its +`Server` directory: ```python -options = ServerOptions() -options.with_auto_downloaded_server() # downloads + caches a self-contained server, no .NET needed -``` +from ravendb_embedded import EmbeddedServer, ServerOptions -The package detects the host operating system and architecture at runtime, so the same Python -configuration is portable across supported Windows, Linux, and macOS machines. +options = ServerOptions() +options.with_external_server("/path/to/extracted/Server") -Walkthrough: [`labs/03-on-demand-server.md`](labs/03-on-demand-server.md). -Self-contained builds remove the system .NET requirement, but the normal RavenDB OS dependencies -still apply. In particular, minimal Linux images may need their distribution's ICU package. +with EmbeddedServer() as server: + server.start_server(options) + with server.get_document_store("MyDatabase") as store: + ... +``` -### Don't manage a server at all (tests) +The native apphost is run directly, so `dotnet` is not called. Server packages are available on +the [RavenDB downloads page](https://ravendb.net/downloads). -For test suites that should not touch .NET or embedded startup, `ravendb-test-driver` can attach -to a RavenDB you run yourself (Docker, testcontainers, a shared CI service) while still giving -each test its own database. See the -[`ravendb-python-testdriver`](https://github.com/ravendb/ravendb-python-testdriver) repository. +Runnable walkthrough: [Lab 02 — external self-contained server](labs/02-embedded-external-server.md). ## Configuration -### `ServerOptions` - -Create `ServerOptions()` and set attributes: +Create a `ServerOptions` instance before starting the server: -- `data_directory`: where database data is stored (defaults to a local `RavenDB` folder). Set a - stable path for data that outlives the process, see - [`labs/05-embedded-persistent.md`](labs/05-embedded-persistent.md). -- `server_url`: the URL to bind (defaults to localhost on a free port). -- `dot_net_path`: path to `dotnet` when it is not on `PATH` (ignored on the self-contained path). -- `command_line_args`: extra [server command-line arguments](https://ravendb.net/docs/article-page/latest/csharp/server/configuration/command-line-arguments). -- `framework_version`: pin an exact .NET version (advanced; leave empty to autodetect the installed runtime). +- `data_directory`: where database data is stored. +- `logs_path`: where RavenDB writes its logs. +- `server_url`: address to bind; the default uses localhost and a free port. +- `dot_net_path`: path to `dotnet` for bundled mode when it is not on `PATH`. +- `command_line_args`: additional + [RavenDB server arguments](https://ravendb.net/docs/article-page/latest/csharp/server/configuration/command-line-arguments). +- `framework_version`: an exact .NET runtime version for advanced bundled-server setups. +- `graceful_shutdown_timeout`: how long to wait before terminating the child process. +- `max_server_startup_time_duration`: maximum server startup time. -### Security +### HTTPS and client certificates -Secure the server with `ServerOptions.secured()`: +Use `ServerOptions.secured()` to start RavenDB over HTTPS: ```python options = ServerOptions() options.secured( - server_pfx_certificate_path, # server certificate (.pfx), required - client_pem_certificate_path, # client certificate (.pem) - server_pfx_certificate_password=None, - ca_certificate_path=None, + "server.pfx", + "client.pem", + server_pfx_certificate_password="", + ca_certificate_path="ca.crt", ) ``` -Runnable example (HTTPS + client-certificate auth): -[`labs/04-embedded-secured.md`](labs/04-embedded-secured.md). +The returned `DocumentStore` receives the client certificate and custom CA automatically. + +Runnable walkthrough: [Lab 04 — secured embedded server](labs/04-embedded-secured.md). + +### Persistent data + +Set `data_directory` to a stable path when data should survive process restarts. Use a temporary +directory for disposable tests. -### Working with data +Runnable walkthrough: [Lab 05 — persistent data](labs/05-embedded-persistent.md). -`get_document_store(database_name)` returns a `DocumentStore` you use like any RavenDB client. For -finer control, build a `DatabaseOptions` (via `DatabaseOptions.from_database_name`) and call -`get_document_store_from_options`; set `skip_creating_database=True` to not auto-create the -database. +### Document stores -Call `open_studio_in_browser()` to open RavenDB Studio in your default browser. +`get_document_store(database_name)` returns a standard RavenDB `DocumentStore` and creates the +database when needed. For finer control, create `DatabaseOptions` with +`DatabaseOptions.from_database_name()` and call `get_document_store_from_options()`. + +Set `skip_creating_database=True` on `DatabaseOptions` when the database is managed elsewhere. +Call `open_studio_in_browser()` to open RavenDB Studio. ## Labs -The `labs/` folder holds runnable, self-checking guides, one per usage case above. The scripts live -in this repository rather than in the installed wheel, so run them from a checkout. Start at -[`labs/README.md`](labs/README.md). +The repository contains runnable, self-checking examples: + +| Lab | Scenario | Needs system .NET? | +|-----|----------|--------------------| +| [01](labs/01-embedded-zero-config.md) | Bundled zero-config server | Yes | +| [02](labs/02-embedded-external-server.md) | Self-contained server you provide | No | +| [03](labs/03-on-demand-server.md) | Automatic platform detection, download, and cache | No | +| [04](labs/04-embedded-secured.md) | HTTPS and client-certificate authentication | Yes | +| [05](labs/05-embedded-persistent.md) | Data that survives server restarts | Yes | + +The scripts live in the repository rather than the installed wheel. Clone or download the +repository, install the package, and run them from the repository root. See the +[complete labs guide](labs/README.md). + +## Links + +- [PyPI](https://pypi.org/project/ravendb-embedded/) +- [Source](https://github.com/ravendb/ravendb-python-embedded) +- [RavenDB Python client documentation](https://ravendb.net/docs/article-page/latest/python) diff --git a/tests/test_on_demand.py b/tests/test_on_demand.py index ec26ff1..226f691 100644 --- a/tests/test_on_demand.py +++ b/tests/test_on_demand.py @@ -1,13 +1,85 @@ import io import tarfile import tempfile +import threading +import time import unittest import zipfile +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path +from ravendb_embedded import on_demand from ravendb_embedded.on_demand import _extract_safely, _platform_download, _platform_download_for, ensure_server +def _server_archive(extension): + archive = io.BytesIO() + payload = b"real cache payload" * 4096 + path = "RavenDB/Server/Raven.Server.dll" + + if extension == "zip": + with zipfile.ZipFile(archive, "w") as bundle: + bundle.writestr(path, payload) + else: + with tarfile.open(fileobj=archive, mode="w:bz2") as bundle: + member = tarfile.TarInfo(path) + member.size = len(payload) + bundle.addfile(member, io.BytesIO(payload)) + + return archive.getvalue() + + +@contextmanager +def _serve_archive(payload, interrupt=False, pause=False): + class ArchiveHandler(BaseHTTPRequestHandler): + request_count = 0 + request_count_lock = threading.Lock() + + def do_GET(self): + with self.request_count_lock: + type(self).request_count += 1 + + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + + midpoint = max(1, len(payload) // 2) + self.wfile.write(payload[:midpoint]) + self.wfile.flush() + if interrupt: + self.close_connection = True + return + if pause: + time.sleep(0.1) + self.wfile.write(payload[midpoint:]) + + def log_message(self, _format, *_args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), ArchiveHandler) + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}", ArchiveHandler + finally: + server.shutdown() + server.server_close() + server_thread.join() + + +@contextmanager +def _download_from(base_url): + original = on_demand._DOWNLOAD_BASE + on_demand._DOWNLOAD_BASE = base_url + try: + yield + finally: + on_demand._DOWNLOAD_BASE = original + + class TestOnDemand(unittest.TestCase): def test_cache_hit_skips_download(self): # A populated cache must be reused without any network call ("by design"). @@ -71,3 +143,49 @@ def test_extract_rejects_path_traversal(self): _extract_safely(bad_zip, dest, "zip") self.assertFalse((Path(work) / "escaped.txt").exists()) + + def test_interrupted_download_does_not_poison_cache(self): + label, extension = _platform_download() + payload = _server_archive(extension) + + with tempfile.TemporaryDirectory() as cache_root: + target = Path(cache_root, "interrupted", label.replace(" ", "_")) + + with _serve_archive(payload, interrupt=True) as (base_url, _): + with _download_from(base_url): + with self.assertRaises((tarfile.ReadError, zipfile.BadZipFile)): + ensure_server(version="interrupted", cache_root=cache_root) + + self.assertFalse(target.exists()) + self.assertEqual([], list(target.parent.glob("download-*"))) + + with _serve_archive(payload) as (base_url, _): + with _download_from(base_url): + resolved = Path(ensure_server(version="interrupted", cache_root=cache_root)) + + self.assertEqual(target / "RavenDB" / "Server", resolved) + self.assertTrue((resolved / "Raven.Server.dll").is_file()) + + def test_concurrent_first_downloads_leave_one_complete_cache(self): + label, extension = _platform_download() + payload = _server_archive(extension) + workers = 4 + start = threading.Barrier(workers) + + with tempfile.TemporaryDirectory() as cache_root: + with _serve_archive(payload, pause=True) as (base_url, handler): + with _download_from(base_url): + + def resolve(): + start.wait() + return ensure_server(version="concurrent", cache_root=cache_root) + + with ThreadPoolExecutor(max_workers=workers) as executor: + resolved = list(executor.map(lambda _: resolve(), range(workers))) + + target = Path(cache_root, "concurrent", label.replace(" ", "_")) + expected = target / "RavenDB" / "Server" + self.assertEqual([str(expected)] * workers, resolved) + self.assertTrue((expected / "Raven.Server.dll").is_file()) + self.assertGreaterEqual(handler.request_count, 2) + self.assertEqual([], list(target.parent.glob("download-*"))) diff --git a/tests/test_startup_errors.py b/tests/test_startup_errors.py new file mode 100644 index 0000000..400730b --- /dev/null +++ b/tests/test_startup_errors.py @@ -0,0 +1,36 @@ +import sys +import tempfile +from datetime import timedelta +from pathlib import Path +from unittest import TestCase + +from ravendb_embedded import EmbeddedServer, ServerOptions + + +class TestStartupErrors(TestCase): + def test_failed_server_process_includes_stderr(self): + with tempfile.TemporaryDirectory() as directory: + server_directory = Path(directory, "Server") + server_directory.mkdir() + Path(server_directory, "Raven.Server.dll").write_text( + "import sys\n" + "sys.stderr.write('intentional startup failure from child process\\n')\n" + "raise SystemExit(23)\n", + encoding="utf-8", + ) + + options = ServerOptions() + options.with_external_server(str(server_directory)) + options.dot_net_path = sys.executable + options.data_directory = str(Path(directory, "data")) + options.logs_path = str(Path(directory, "logs")) + options.max_server_startup_time_duration = timedelta(seconds=10) + + with EmbeddedServer() as server: + with self.assertRaises(RuntimeError) as context: + server.start_server(options) + + message = str(context.exception) + self.assertIn("Unable to start the RavenDB Server", message) + self.assertIn("Error:", message) + self.assertIn("intentional startup failure from child process", message) From 44e889cdd20e7000f36a3f19f5d9a529af80da6c Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Tue, 28 Jul 2026 17:42:35 +0200 Subject: [PATCH 02/35] RavenDB-27140 Make embedded shutdown graceful and bounded --- README.md | 1 + ravendb_embedded/embedded_server.py | 55 ++++++++++++++++--------- ravendb_embedded/options.py | 1 + ravendb_embedded/raven_server_runner.py | 7 +++- tests/test_shutdown.py | 34 +++++++++++++++ 5 files changed, 78 insertions(+), 20 deletions(-) create mode 100644 tests/test_shutdown.py diff --git a/README.md b/README.md index 42e71bd..7987328 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ Create a `ServerOptions` instance before starting the server: [RavenDB server arguments](https://ravendb.net/docs/article-page/latest/csharp/server/configuration/command-line-arguments). - `framework_version`: an exact .NET runtime version for advanced bundled-server setups. - `graceful_shutdown_timeout`: how long to wait before terminating the child process. +- `process_kill_timeout`: how long to wait for the process after terminating or killing it. - `max_server_startup_time_duration`: maximum server startup time. ### HTTPS and client certificates diff --git a/ravendb_embedded/embedded_server.py b/ravendb_embedded/embedded_server.py index 9d89ce8..165af67 100644 --- a/ravendb_embedded/embedded_server.py +++ b/ravendb_embedded/embedded_server.py @@ -31,6 +31,7 @@ def __init__(self): self.client_pem_certificate_path: Optional[str] = None self.trust_store_path: Optional[str] = None self._graceful_shutdown_timeout: Optional[timedelta] = None + self._process_kill_timeout: Optional[timedelta] = None self.logger = logging.Logger(self.__class__.__name__, logging.DEBUG) def __enter__(self): @@ -47,6 +48,7 @@ def start_server(self, options_param: ServerOptions = None) -> None: options = options_param or ServerOptions() self._graceful_shutdown_timeout = options.graceful_shutdown_timeout + self._process_kill_timeout = options.process_kill_timeout start_server = Lazy(lambda: self._run_server(options)) @@ -115,29 +117,44 @@ def _shutdown_server_process(self, process: subprocess.Popen) -> None: if not process or process.poll() is not None: return - with process: - if process.poll() is not None: # Check if the process has already terminated - return + graceful_timeout = (self._graceful_shutdown_timeout or timedelta(seconds=30)).total_seconds() + kill_timeout = (self._process_kill_timeout or timedelta(seconds=5)).total_seconds() - try: - self._log_debug("Try shutdown server gracefully.") - with process.stdin: - process.stdin.write("shutdown no-confirmation\n".encode("utf-8")) + try: + self._log_debug("Trying to shut down the server gracefully.") + if process.stdin is None: + raise RuntimeError("The server process stdin is not available.") - if process.wait(self._graceful_shutdown_timeout.total_seconds()) == 0: - return + process.stdin.write(b"shutdown no-confirmation\n") + process.stdin.flush() + process.wait(timeout=graceful_timeout) + return + except Exception as error: + self._log_debug( + f"Failed to gracefully shut down the server in {self._graceful_shutdown_timeout}. Error: {error}" + ) - except Exception as e: - self._log_debug( - f"Failed to gracefully shutdown server in {self._graceful_shutdown_timeout}. Error: {e}" - ) + if process.poll() is not None: + return - try: - self._log_debug("Killing global server") - process.terminate() # Use terminate() instead of kill() - process.wait() - except Exception as e: - self._log_debug(f"Failed to terminate server process. Error: {e}") + try: + self._log_debug("Terminating the server process.") + process.terminate() + process.wait(timeout=kill_timeout) + return + except subprocess.TimeoutExpired: + self._log_debug(f"The server did not terminate in {self._process_kill_timeout}; killing it.") + except Exception as error: + self._log_debug(f"Failed to terminate the server process. Error: {error}") + + if process.poll() is not None: + return + + try: + process.kill() + process.wait(timeout=kill_timeout) + except Exception as error: + self._log_debug(f"Failed to kill the server process in {self._process_kill_timeout}. Error: {error}") def _run_server(self, options: ServerOptions) -> Tuple[str, subprocess.Popen]: try: diff --git a/ravendb_embedded/options.py b/ravendb_embedded/options.py index 535b81a..ebc7b99 100644 --- a/ravendb_embedded/options.py +++ b/ravendb_embedded/options.py @@ -53,6 +53,7 @@ def __init__(self): self.accept_eula: bool = True self.server_url: Optional[str] = None self.graceful_shutdown_timeout: timedelta = timedelta(seconds=30) + self.process_kill_timeout: timedelta = timedelta(seconds=5) self.max_server_startup_time_duration: timedelta = timedelta(minutes=1) self.command_line_args: list[str] = list() self.security: Optional[SecurityOptions] = None diff --git a/ravendb_embedded/raven_server_runner.py b/ravendb_embedded/raven_server_runner.py index 30923df..a4e1f28 100644 --- a/ravendb_embedded/raven_server_runner.py +++ b/ravendb_embedded/raven_server_runner.py @@ -100,7 +100,12 @@ def run(options: ServerOptions) -> subprocess.Popen: command_line_args.insert(1, framework_version) command_line_args.insert(1, "--fx-version") - process_builder = subprocess.Popen(command_line_args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + process_builder = subprocess.Popen( + command_line_args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) process = process_builder return process diff --git a/tests/test_shutdown.py b/tests/test_shutdown.py new file mode 100644 index 0000000..98013b8 --- /dev/null +++ b/tests/test_shutdown.py @@ -0,0 +1,34 @@ +import sys +import tempfile +from pathlib import Path +from unittest import TestCase + +from ravendb_embedded import EmbeddedServer, ServerOptions + + +class TestShutdown(TestCase): + def test_close_sends_graceful_shutdown_command(self): + with tempfile.TemporaryDirectory() as directory: + server_directory = Path(directory, "Server") + server_directory.mkdir() + shutdown_marker = Path(directory, "graceful-shutdown.txt") + Path(server_directory, "Raven.Server.dll").write_text( + "import pathlib\n" + "import sys\n" + "print('Server available on: http://127.0.0.1:12345', flush=True)\n" + "command = sys.stdin.readline().strip()\n" + f"pathlib.Path({str(shutdown_marker)!r}).write_text(command, encoding='utf-8')\n", + encoding="utf-8", + ) + + options = ServerOptions() + options.with_external_server(str(server_directory)) + options.dot_net_path = sys.executable + options.data_directory = str(Path(directory, "data")) + options.logs_path = str(Path(directory, "logs")) + + server = EmbeddedServer() + server.start_server(options) + server.close() + + self.assertEqual("shutdown no-confirmation", shutdown_marker.read_text(encoding="utf-8")) From ba3934432b860866ba8c1b899c9360853cf01296 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Tue, 28 Jul 2026 17:45:10 +0200 Subject: [PATCH 03/35] RavenDB-27140 Use RavenDB certificate thumbprints for admin access --- ravendb_embedded/raven_server_runner.py | 2 +- tests/certificates.py | 96 +++++++++++++++++++++++++ tests/test_secured_basic.py | 20 +++++- 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/ravendb_embedded/raven_server_runner.py b/ravendb_embedded/raven_server_runner.py index a4e1f28..2a0548a 100644 --- a/ravendb_embedded/raven_server_runner.py +++ b/ravendb_embedded/raven_server_runner.py @@ -82,7 +82,7 @@ def run(options: ServerOptions) -> subprocess.Popen: cert_data = cert_file.read() cert = x509.load_pem_x509_certificate(cert_data, default_backend()) - thumbprint = cert.fingerprint(hashes.SHA256()).hex() + thumbprint = cert.fingerprint(hashes.SHA1()).hex().upper() command_line_args.append(f"--Security.WellKnownCertificates.Admin={thumbprint}") else: diff --git a/tests/certificates.py b/tests/certificates.py index ef5c3d0..b8014ad 100644 --- a/tests/certificates.py +++ b/tests/certificates.py @@ -67,3 +67,99 @@ def generate_self_signed_certificates(directory): client_pem.write_bytes(key_pem + certificate_pem) ca_crt.write_bytes(certificate_pem) return str(server_pfx), str(client_pem), str(ca_crt) + + +def generate_separate_server_and_client_certificates(directory): + ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Embedded Test CA")]) + now = datetime.datetime.now(datetime.timezone.utc) + ca_certificate = ( + x509.CertificateBuilder() + .subject_name(ca_name) + .issuer_name(ca_name) + .public_key(ca_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=3650)) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=False, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=True, + crl_sign=True, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .sign(ca_key, hashes.SHA256()) + ) + + def issue_certificate(common_name, extended_key_usage, subject_alternative_name=None): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + builder = ( + x509.CertificateBuilder() + .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)])) + .issuer_name(ca_name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=3650)) + .add_extension( + x509.KeyUsage( + digital_signature=True, + key_encipherment=True, + content_commitment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=False, + crl_sign=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension(x509.ExtendedKeyUsage(extended_key_usage), critical=False) + ) + if subject_alternative_name: + builder = builder.add_extension(subject_alternative_name, critical=False) + return key, builder.sign(ca_key, hashes.SHA256()) + + server_key, server_certificate = issue_certificate( + "localhost", + [ExtendedKeyUsageOID.SERVER_AUTH, ExtendedKeyUsageOID.CLIENT_AUTH], + x509.SubjectAlternativeName( + [x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))] + ), + ) + client_key, client_certificate = issue_certificate( + "embedded-admin", + [ExtendedKeyUsageOID.CLIENT_AUTH], + ) + + server_pfx = Path(directory, "server.pfx") + client_pem = Path(directory, "client.pem") + ca_crt = Path(directory, "ca.crt") + server_pfx.write_bytes( + pkcs12.serialize_key_and_certificates( + b"localhost", + server_key, + server_certificate, + [ca_certificate], + serialization.NoEncryption(), + ) + ) + client_pem.write_bytes( + client_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + ) + + client_certificate.public_bytes(serialization.Encoding.PEM) + ) + ca_crt.write_bytes(ca_certificate.public_bytes(serialization.Encoding.PEM)) + return str(server_pfx), str(client_pem), str(ca_crt) diff --git a/tests/test_secured_basic.py b/tests/test_secured_basic.py index cf8bcf7..40e7b99 100644 --- a/tests/test_secured_basic.py +++ b/tests/test_secured_basic.py @@ -7,7 +7,7 @@ from ravendb_embedded.options import ServerOptions, DatabaseOptions from ravendb_embedded.provide import CopyServerFromNugetProvider from tests import Person -from tests.certificates import generate_self_signed_certificates +from tests.certificates import generate_self_signed_certificates, generate_separate_server_and_client_certificates class TestSecuredBasic(TestCase): @@ -34,3 +34,21 @@ def test_secured_embedded(self): session.save_changes() finally: shutil.rmtree(temp_dir) + + def test_secured_embedded_with_separate_admin_certificate(self): + with tempfile.TemporaryDirectory() as temp_dir: + server_pfx, client_pem, ca_crt = generate_separate_server_and_client_certificates(temp_dir) + with EmbeddedServer() as embedded: + server_options = ServerOptions() + server_options.secured(server_pfx, client_pem, ca_certificate_path=ca_crt) + server_options.data_directory = str(Path(temp_dir, "RavenDB")) + server_options.logs_path = str(Path(temp_dir, "Logs")) + server_options.provider = CopyServerFromNugetProvider() + embedded.start_server(server_options) + + with embedded.get_document_store("Test") as store: + with store.open_session() as session: + person = Person() + person.name = "Separate admin certificate" + session.store(person, "people/1") + session.save_changes() From 58cb023b55fab473273c21f2eaa8e6f1ae905851 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Tue, 28 Jul 2026 17:47:11 +0200 Subject: [PATCH 04/35] RavenDB-27140 Close open document stores safely --- ravendb_embedded/embedded_server.py | 4 ++-- tests/test_basic.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/ravendb_embedded/embedded_server.py b/ravendb_embedded/embedded_server.py index 165af67..42e22c0 100644 --- a/ravendb_embedded/embedded_server.py +++ b/ravendb_embedded/embedded_server.py @@ -75,7 +75,7 @@ def _initialize_document_store(self, database_name, options): store.trust_store_path = self.trust_store_path store.conventions = options.conventions - store.add_after_close(lambda: self.document_stores.pop(database_name)) + store.add_after_close(lambda: self.document_stores.pop(database_name, None)) store.initialize() @@ -309,7 +309,7 @@ def close(self): process = lazy.get_value()[1] self._shutdown_server_process(process) - for key, value in self.document_stores.items(): + for value in list(self.document_stores.values()): if value.created: value.get_value().close() diff --git a/tests/test_basic.py b/tests/test_basic.py index 526eb08..3799677 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -8,6 +8,25 @@ class BasicTest(TestCase): + def test_close_disposes_open_document_stores(self): + with tempfile.TemporaryDirectory() as temp_dir: + embedded = EmbeddedServer() + server_options = ServerOptions() + server_options.data_directory = str(Path(temp_dir, "RavenDB")) + server_options.logs_path = str(Path(temp_dir, "Logs")) + server_options.provider = CopyServerFromNugetProvider() + embedded.start_server(server_options) + + first = embedded.get_document_store("First") + second = embedded.get_document_store("Second") + + embedded.close() + + self.assertTrue(first.disposed) + self.assertTrue(second.disposed) + self.assertEqual({}, embedded.document_stores) + self.assertIsNone(embedded.server_task) + def test_embedded(self): temp_dir = tempfile.mkdtemp() try: From ca84b4fb22ee7f0bb6feab2c1b3578d412f729f4 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Tue, 28 Jul 2026 17:49:02 +0200 Subject: [PATCH 05/35] RavenDB-27140 Support external certificate loaders --- README.md | 13 ++++++++++ ravendb_embedded/options.py | 23 ++++++++++++++++++ ravendb_embedded/raven_server_runner.py | 4 ++-- tests/test_secured_basic.py | 32 +++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7987328..90c1c86 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,19 @@ options.secured( The returned `DocumentStore` receives the client certificate and custom CA automatically. +When another process supplies the server certificate, use `secured_with_certificate_exec()`: + +```python +options.secured_with_certificate_exec( + certificate_exec="python", + certificate_arguments='"load_certificate.py" "server.pfx"', + client_pem_certificate_path="client.pem", + ca_certificate_path="ca.crt", +) +``` + +The certificate command must write the PFX bytes to standard output. + Runnable walkthrough: [Lab 04 — secured embedded server](labs/04-embedded-secured.md). ### Persistent data diff --git a/ravendb_embedded/options.py b/ravendb_embedded/options.py index ebc7b99..b70cc10 100644 --- a/ravendb_embedded/options.py +++ b/ravendb_embedded/options.py @@ -94,6 +94,29 @@ def secured( return self + def secured_with_certificate_exec( + self, + certificate_exec: str, + certificate_arguments: str, + client_pem_certificate_path: str, + ca_certificate_path: str = None, + ) -> "ServerOptions": + if certificate_exec is None: + raise ValueError("certificate_exec cannot be None") + if certificate_arguments is None: + raise ValueError("certificate_arguments cannot be None") + if client_pem_certificate_path is None: + raise ValueError("client_pem_certificate_path cannot be None") + if self.security is not None: + raise RuntimeError("The security has already been set up for this ServerOptions object") + + self.security = SecurityOptions() + self.security.certificate_exec = certificate_exec + self.security.certificate_arguments = certificate_arguments + self.security.client_pem_certificate_path = client_pem_certificate_path + self.security.ca_certificate_path = ca_certificate_path + return self + def with_external_server(self, server_location: str) -> None: self.provider = ExternalServerProvider(server_location) # A directory is already a runnable server: run it in place, so we neither copy it nor diff --git a/ravendb_embedded/raven_server_runner.py b/ravendb_embedded/raven_server_runner.py index 2a0548a..9c34018 100644 --- a/ravendb_embedded/raven_server_runner.py +++ b/ravendb_embedded/raven_server_runner.py @@ -73,8 +73,8 @@ def run(options: ServerOptions) -> subprocess.Popen: elif options.security.certificate_exec: command_line_args.extend( [ - f"--Security.Certificate.Exec={options.security.certificate_exec}", - f"--Security.Certificate.Exec.Arguments={options.security.certificate_arguments}", + f"--Security.Certificate.Load.Exec={options.security.certificate_exec}", + f"--Security.Certificate.Load.Exec.Arguments={options.security.certificate_arguments}", ] ) if options.security.client_pem_certificate_path: diff --git a/tests/test_secured_basic.py b/tests/test_secured_basic.py index 40e7b99..820bcc0 100644 --- a/tests/test_secured_basic.py +++ b/tests/test_secured_basic.py @@ -1,4 +1,5 @@ import shutil +import sys import tempfile from pathlib import Path from unittest import TestCase @@ -52,3 +53,34 @@ def test_secured_embedded_with_separate_admin_certificate(self): person.name = "Separate admin certificate" session.store(person, "people/1") session.save_changes() + + def test_secured_embedded_with_certificate_exec(self): + with tempfile.TemporaryDirectory() as temp_dir: + server_pfx, client_pem, ca_crt = generate_separate_server_and_client_certificates(temp_dir) + certificate_loader = Path(temp_dir, "load_certificate.py") + certificate_loader.write_text( + "import pathlib\n" + "import sys\n" + "sys.stdout.buffer.write(pathlib.Path(sys.argv[1]).read_bytes())\n", + encoding="utf-8", + ) + + with EmbeddedServer() as embedded: + server_options = ServerOptions() + server_options.secured_with_certificate_exec( + sys.executable, + f'"{certificate_loader}" "{server_pfx}"', + client_pem, + ca_certificate_path=ca_crt, + ) + server_options.data_directory = str(Path(temp_dir, "RavenDB")) + server_options.logs_path = str(Path(temp_dir, "Logs")) + server_options.provider = CopyServerFromNugetProvider() + embedded.start_server(server_options) + + with embedded.get_document_store("Test") as store: + with store.open_session() as session: + person = Person() + person.name = "Certificate loader" + session.store(person, "people/1") + session.save_changes() From 3ad699630590e419aa5645d52268e5c8ab0eedbc Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Tue, 28 Jul 2026 17:51:04 +0200 Subject: [PATCH 06/35] RavenDB-27140 Preserve runtime matcher failures --- .../runtime_framework_version_matcher.py | 5 +++-- tests/test_runtime_framework_version_matcher.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/ravendb_embedded/runtime_framework_version_matcher.py b/ravendb_embedded/runtime_framework_version_matcher.py index d9fb98d..08dc2e5 100644 --- a/ravendb_embedded/runtime_framework_version_matcher.py +++ b/ravendb_embedded/runtime_framework_version_matcher.py @@ -33,7 +33,7 @@ def match_runtime(runtime: RuntimeFrameworkVersion, runtimes: List[RuntimeFramew if runtime.match(version): return str(version) - available_runtimes = "- ".join([str(r) for r in sorted_runtimes]) + available_runtimes = "\n- ".join([str(r) for r in sorted_runtimes]) raise RuntimeError( f"Could not find a matching runtime for '{runtime}'. Available runtimes:\n- {available_runtimes}" ) @@ -56,6 +56,7 @@ def get_framework_versions(options: ServerOptions): process_command = [options.dot_net_path, "--info"] runtimes = [] + process = None try: with subprocess.Popen( @@ -86,7 +87,7 @@ def get_framework_versions(options: ServerOptions): except Exception as e: raise RuntimeError("Unable to execute dotnet to retrieve list of installed runtimes") from e finally: - if process: + if process is not None and process.poll() is None: process.kill() return runtimes diff --git a/tests/test_runtime_framework_version_matcher.py b/tests/test_runtime_framework_version_matcher.py index d0f0a58..fe988a2 100644 --- a/tests/test_runtime_framework_version_matcher.py +++ b/tests/test_runtime_framework_version_matcher.py @@ -1,3 +1,5 @@ +import tempfile +from pathlib import Path from unittest import TestCase from ravendb_embedded.options import ServerOptions @@ -78,6 +80,7 @@ def test_match_4(self): "Could not find a matching runtime for '3.1.4+'. Available runtimes:", str(context.exception), ) + self.assertIn("\n- 5.0.4\n- 5.0.3\n- 5.0.0-rc.2.20475.17", str(context.exception)) with self.assertRaises(RuntimeError) as context: RuntimeFrameworkVersion("6.0.0+-preview.6.21352.12") @@ -100,6 +103,20 @@ def test_match_4(self): str(context.exception), ) + def test_missing_dotnet_preserves_execution_error(self): + with tempfile.TemporaryDirectory() as directory: + options = ServerOptions() + options.dot_net_path = str(Path(directory, "missing-dotnet")) + + with self.assertRaises(RuntimeError) as context: + RuntimeFrameworkVersionMatcher.get_framework_versions(options) + + self.assertEqual( + "Unable to execute dotnet to retrieve list of installed runtimes", + str(context.exception), + ) + self.assertIsInstance(context.exception.__cause__, OSError) + def get_runtimes(self): result = [ RuntimeFrameworkVersion("2.1.3"), From 3e5e3a3bafb369c4deff4f5ea8613b17115abaf9 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Tue, 28 Jul 2026 17:52:57 +0200 Subject: [PATCH 07/35] RavenDB-27140 Handle existing databases by exception type --- ravendb_embedded/embedded_server.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/ravendb_embedded/embedded_server.py b/ravendb_embedded/embedded_server.py index 42e22c0..00fa529 100644 --- a/ravendb_embedded/embedded_server.py +++ b/ravendb_embedded/embedded_server.py @@ -13,7 +13,7 @@ import webbrowser from ravendb import DocumentStore, CreateDatabaseOperation -from ravendb.exceptions.raven_exceptions import RavenException +from ravendb.exceptions.raven_exceptions import ConcurrencyException, RavenException from ravendb.tools.utils import Stopwatch from ravendb_embedded.options import ServerOptions, DatabaseOptions from ravendb_embedded.raven_server_runner import RavenServerRunner @@ -99,12 +99,8 @@ def get_document_store_from_options(self, options: DatabaseOptions) -> DocumentS def _try_create_database(self, options: DatabaseOptions, store: DocumentStore) -> None: try: store.maintenance.server.send(CreateDatabaseOperation(options.database_record)) - except Exception as e: - # Expected behavior when the database already exists - if "conflict" in e.args[0] or "already exists" in e.args[0]: - self._log_debug(f"{options.database_record.database_name} already exists.") - else: - raise e + except ConcurrencyException: + self._log_debug(f"{options.database_record.database_name} already exists.") def get_server_uri(self) -> str: server = self.server_task From de2f6156b72251ca0a33667b0af6e372ae66babf Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Tue, 28 Jul 2026 18:50:11 +0200 Subject: [PATCH 08/35] RavenDB-27140 Make document store creation thread-safe --- ravendb_embedded/embedded_server.py | 35 +++++++++++++++++++++-------- tests/test_basic.py | 16 +++++++++++++ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/ravendb_embedded/embedded_server.py b/ravendb_embedded/embedded_server.py index 00fa529..08d4e8f 100644 --- a/ravendb_embedded/embedded_server.py +++ b/ravendb_embedded/embedded_server.py @@ -8,7 +8,7 @@ import time from datetime import timedelta from typing import Optional, List, Callable, Tuple, Generic, TypeVar, Dict, IO -from threading import Thread +from threading import Lock, RLock, Thread from queue import Queue import webbrowser @@ -28,6 +28,7 @@ class EmbeddedServer: def __init__(self): self.server_task: Optional[Lazy[Tuple[str, subprocess.Popen]]] = None self.document_stores = {} + self._document_stores_lock = RLock() self.client_pem_certificate_path: Optional[str] = None self.trust_store_path: Optional[str] = None self._graceful_shutdown_timeout: Optional[timedelta] = None @@ -75,7 +76,11 @@ def _initialize_document_store(self, database_name, options): store.trust_store_path = self.trust_store_path store.conventions = options.conventions - store.add_after_close(lambda: self.document_stores.pop(database_name, None)) + def remove_store(): + with self._document_stores_lock: + self.document_stores.pop(database_name, None) + + store.add_after_close(remove_store) store.initialize() @@ -92,9 +97,13 @@ def get_document_store_from_options(self, options: DatabaseOptions) -> DocumentS self._log_debug(f"Creating document store for '{database_name}'.") - lazy = Lazy(lambda: self._initialize_document_store(database_name, options)) + with self._document_stores_lock: + lazy = self.document_stores.get(database_name) + if lazy is None: + lazy = Lazy(lambda: self._initialize_document_store(database_name, options)) + self.document_stores[database_name] = lazy - return self.document_stores.setdefault(database_name, lazy).get_value() + return lazy.get_value() def _try_create_database(self, options: DatabaseOptions, store: DocumentStore) -> None: try: @@ -305,11 +314,15 @@ def close(self): process = lazy.get_value()[1] self._shutdown_server_process(process) - for value in list(self.document_stores.values()): + with self._document_stores_lock: + stores = list(self.document_stores.values()) + + for value in stores: if value.created: value.get_value().close() - self.document_stores.clear() + with self._document_stores_lock: + self.document_stores.clear() self.server_task = None @@ -318,13 +331,17 @@ def __init__(self, func: Callable[[], _T]): self.func = func self._value = None self._created = False + self._lock = Lock() def get_value(self) -> _T: if not self._created: - self._value = self.func() - self._created = True + with self._lock: + if not self._created: + self._value = self.func() + self._created = True return self._value @property def created(self): - return self._created + with self._lock: + return self._created diff --git a/tests/test_basic.py b/tests/test_basic.py index 3799677..0862cf8 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,5 +1,6 @@ import shutil import tempfile +from concurrent.futures import ThreadPoolExecutor from pathlib import Path from unittest import TestCase @@ -8,6 +9,21 @@ class BasicTest(TestCase): + def test_concurrent_calls_share_one_document_store(self): + with tempfile.TemporaryDirectory() as temp_dir: + with EmbeddedServer() as embedded: + server_options = ServerOptions() + server_options.data_directory = str(Path(temp_dir, "RavenDB")) + server_options.logs_path = str(Path(temp_dir, "Logs")) + server_options.provider = CopyServerFromNugetProvider() + embedded.start_server(server_options) + + with ThreadPoolExecutor(max_workers=8) as executor: + stores = list(executor.map(lambda _: embedded.get_document_store("Shared"), range(8))) + + self.assertEqual(1, len({id(store) for store in stores})) + stores[0].close() + def test_close_disposes_open_document_stores(self): with tempfile.TemporaryDirectory() as temp_dir: embedded = EmbeddedServer() From eb8f77602022969384db80b0baa10b0c85893382 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Tue, 28 Jul 2026 18:53:13 +0200 Subject: [PATCH 09/35] RavenDB-27140 Synchronize server lifecycle cleanup --- ravendb_embedded/embedded_server.py | 189 +++++++++++++++++----------- tests/test_basic.py | 52 ++++++++ tests/test_shutdown.py | 47 +++++++ 3 files changed, 217 insertions(+), 71 deletions(-) diff --git a/ravendb_embedded/embedded_server.py b/ravendb_embedded/embedded_server.py index 08d4e8f..facb3a7 100644 --- a/ravendb_embedded/embedded_server.py +++ b/ravendb_embedded/embedded_server.py @@ -27,6 +27,9 @@ class EmbeddedServer: # singleton def __init__(self): self.server_task: Optional[Lazy[Tuple[str, subprocess.Popen]]] = None + self._server_options: Optional[ServerOptions] = None + self._lifecycle_lock = RLock() + self._exit_handler: Optional[Callable[[], None]] = None self.document_stores = {} self._document_stores_lock = RLock() self.client_pem_certificate_path: Optional[str] = None @@ -48,21 +51,29 @@ def _log_debug(self, message: str) -> None: def start_server(self, options_param: ServerOptions = None) -> None: options = options_param or ServerOptions() - self._graceful_shutdown_timeout = options.graceful_shutdown_timeout - self._process_kill_timeout = options.process_kill_timeout + with self._lifecycle_lock: + if self.server_task is not None: + raise RuntimeError("The server was already started") - start_server = Lazy(lambda: self._run_server(options)) + self._server_options = options + self._graceful_shutdown_timeout = options.graceful_shutdown_timeout + self._process_kill_timeout = options.process_kill_timeout - if self.server_task and self.server_task.created and self.server_task != start_server: - raise RuntimeError("The server was already started") + start_server = Lazy(lambda: self._run_server(options)) + self.server_task = start_server - self.server_task = start_server + if options.security is not None: + self.client_pem_certificate_path = options.security.client_pem_certificate_path + self.trust_store_path = options.security.ca_certificate_path - if options.security is not None: - self.client_pem_certificate_path = options.security.client_pem_certificate_path - self.trust_store_path = options.security.ca_certificate_path - - start_server.get_value() + try: + start_server.get_value() + except Exception: + if self.server_task is start_server: + self.server_task = None + self._server_options = None + self._unregister_exit_handler() + raise def get_document_store(self, database: str) -> DocumentStore: return self.get_document_store_from_options(DatabaseOptions.from_database_name(database)) @@ -91,19 +102,22 @@ def remove_store(): def get_document_store_from_options(self, options: DatabaseOptions) -> DocumentStore: database_name = options.database_record.database_name - if not database_name or database_name.isspace(): raise ValueError("DatabaseName cannot be null or whitespace") - self._log_debug(f"Creating document store for '{database_name}'.") + with self._lifecycle_lock: + if self.server_task is None: + raise RuntimeError("Please run start_server() before trying to use the server.") - with self._document_stores_lock: - lazy = self.document_stores.get(database_name) - if lazy is None: - lazy = Lazy(lambda: self._initialize_document_store(database_name, options)) - self.document_stores[database_name] = lazy + self._log_debug(f"Creating document store for '{database_name}'.") - return lazy.get_value() + with self._document_stores_lock: + lazy = self.document_stores.get(database_name) + if lazy is None: + lazy = Lazy(lambda: self._initialize_document_store(database_name, options)) + self.document_stores[database_name] = lazy + + return lazy.get_value() def _try_create_database(self, options: DatabaseOptions, store: DocumentStore) -> None: try: @@ -112,54 +126,83 @@ def _try_create_database(self, options: DatabaseOptions, store: DocumentStore) - self._log_debug(f"{options.database_record.database_name} already exists.") def get_server_uri(self) -> str: - server = self.server_task - if server is None: - raise RuntimeError("Please run start_server() before trying to use the server.") + with self._lifecycle_lock: + server = self.server_task + if server is None: + raise RuntimeError("Please run start_server() before trying to use the server.") - return server.get_value()[0] + return server.get_value()[0] def _shutdown_server_process(self, process: subprocess.Popen) -> None: - if not process or process.poll() is not None: + if not process: return - graceful_timeout = (self._graceful_shutdown_timeout or timedelta(seconds=30)).total_seconds() - kill_timeout = (self._process_kill_timeout or timedelta(seconds=5)).total_seconds() - try: - self._log_debug("Trying to shut down the server gracefully.") - if process.stdin is None: - raise RuntimeError("The server process stdin is not available.") + if process.poll() is not None: + return - process.stdin.write(b"shutdown no-confirmation\n") - process.stdin.flush() - process.wait(timeout=graceful_timeout) - return - except Exception as error: - self._log_debug( - f"Failed to gracefully shut down the server in {self._graceful_shutdown_timeout}. Error: {error}" - ) + graceful_timeout = (self._graceful_shutdown_timeout or timedelta(seconds=30)).total_seconds() + kill_timeout = (self._process_kill_timeout or timedelta(seconds=5)).total_seconds() - if process.poll() is not None: - return + try: + self._log_debug("Trying to shut down the server gracefully.") + if process.stdin is None: + raise RuntimeError("The server process stdin is not available.") + + process.stdin.write(b"shutdown no-confirmation\n") + process.stdin.flush() + process.wait(timeout=graceful_timeout) + return + except Exception as error: + self._log_debug( + f"Failed to gracefully shut down the server in {self._graceful_shutdown_timeout}. Error: {error}" + ) + + if process.poll() is not None: + return - try: - self._log_debug("Terminating the server process.") - process.terminate() - process.wait(timeout=kill_timeout) - return - except subprocess.TimeoutExpired: - self._log_debug(f"The server did not terminate in {self._process_kill_timeout}; killing it.") - except Exception as error: - self._log_debug(f"Failed to terminate the server process. Error: {error}") + try: + self._log_debug("Terminating the server process.") + process.terminate() + process.wait(timeout=kill_timeout) + return + except subprocess.TimeoutExpired: + self._log_debug(f"The server did not terminate in {self._process_kill_timeout}; killing it.") + except Exception as error: + self._log_debug(f"Failed to terminate the server process. Error: {error}") + + if process.poll() is not None: + return - if process.poll() is not None: - return + try: + process.kill() + process.wait(timeout=kill_timeout) + except Exception as error: + self._log_debug(f"Failed to kill the server process in {self._process_kill_timeout}. Error: {error}") + finally: + if process.poll() is not None: + self._close_process_streams(process) - try: - process.kill() - process.wait(timeout=kill_timeout) - except Exception as error: - self._log_debug(f"Failed to kill the server process in {self._process_kill_timeout}. Error: {error}") + @staticmethod + def _close_process_streams(process: subprocess.Popen) -> None: + for stream in (process.stdin, process.stdout, process.stderr): + if stream is None or stream.closed: + continue + try: + stream.close() + except Exception: + pass + + def _register_exit_handler(self, process: subprocess.Popen) -> None: + self._unregister_exit_handler() + self._exit_handler = lambda: self._shutdown_server_process(process) + atexit.register(self._exit_handler) + + def _unregister_exit_handler(self) -> None: + if self._exit_handler is None: + return + atexit.unregister(self._exit_handler) + self._exit_handler = None def _run_server(self, options: ServerOptions) -> Tuple[str, subprocess.Popen]: try: @@ -178,8 +221,6 @@ def _run_server(self, options: ServerOptions) -> Tuple[str, subprocess.Popen]: self._log_debug("Starting global server") - atexit.register(lambda: self._shutdown_server_process(process)) - url_ref: Dict[str, Optional[str]] = {"value": None} startup_duration = Stopwatch.create_started() @@ -195,6 +236,7 @@ def _run_server(self, options: ServerOptions) -> Tuple[str, subprocess.Popen]: self._shutdown_server_process(process) raise RuntimeError(self.build_startup_exception_message(output_string, error_string, process)) + self._register_exit_handler(process) return url_ref["value"], process @staticmethod @@ -307,23 +349,28 @@ def open_studio_in_browser(self): raise RuntimeError(e) def close(self): - lazy = self.server_task - if lazy is None or not lazy.created: - return + with self._lifecycle_lock: + lazy = self.server_task + if lazy is None or not lazy.created: + return + + self.server_task = None + self._unregister_exit_handler() + process = lazy.get_value()[1] + self._shutdown_server_process(process) - process = lazy.get_value()[1] - self._shutdown_server_process(process) + with self._document_stores_lock: + stores = list(self.document_stores.values()) - with self._document_stores_lock: - stores = list(self.document_stores.values()) + for value in stores: + if value.created: + value.get_value().close() - for value in stores: - if value.created: - value.get_value().close() + self.document_stores.clear() - with self._document_stores_lock: - self.document_stores.clear() - self.server_task = None + self._server_options = None + self.client_pem_certificate_path = None + self.trust_store_path = None class Lazy(Generic[_T]): diff --git a/tests/test_basic.py b/tests/test_basic.py index 0862cf8..b432b88 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -2,6 +2,7 @@ import tempfile from concurrent.futures import ThreadPoolExecutor from pathlib import Path +from threading import Event, Thread from unittest import TestCase from ravendb_embedded import EmbeddedServer, ServerOptions, CopyServerFromNugetProvider, DatabaseOptions @@ -9,6 +10,57 @@ class BasicTest(TestCase): + def test_close_waits_for_document_store_initialization(self): + initialization_started = Event() + continue_initialization = Event() + close_finished = Event() + results = {} + errors = [] + + class PausingEmbeddedServer(EmbeddedServer): + def _initialize_document_store(self, database_name, options): + initialization_started.set() + continue_initialization.wait() + return super()._initialize_document_store(database_name, options) + + with tempfile.TemporaryDirectory() as temp_dir: + embedded = PausingEmbeddedServer() + server_options = ServerOptions() + server_options.data_directory = str(Path(temp_dir, "RavenDB")) + server_options.logs_path = str(Path(temp_dir, "Logs")) + server_options.provider = CopyServerFromNugetProvider() + embedded.start_server(server_options) + + def get_store(): + try: + results["store"] = embedded.get_document_store("ConcurrentClose") + except Exception as error: + errors.append(error) + + def close_server(): + try: + embedded.close() + except Exception as error: + errors.append(error) + finally: + close_finished.set() + + get_thread = Thread(target=get_store, daemon=True) + close_thread = Thread(target=close_server, daemon=True) + get_thread.start() + self.assertTrue(initialization_started.wait(10)) + close_thread.start() + + self.assertFalse(close_finished.wait(0.2)) + continue_initialization.set() + get_thread.join(30) + close_thread.join(30) + + self.assertFalse(get_thread.is_alive()) + self.assertFalse(close_thread.is_alive()) + self.assertEqual([], errors) + self.assertTrue(results["store"].disposed) + def test_concurrent_calls_share_one_document_store(self): with tempfile.TemporaryDirectory() as temp_dir: with EmbeddedServer() as embedded: diff --git a/tests/test_shutdown.py b/tests/test_shutdown.py index 98013b8..dbb39a3 100644 --- a/tests/test_shutdown.py +++ b/tests/test_shutdown.py @@ -1,12 +1,54 @@ import sys import tempfile +from concurrent.futures import ThreadPoolExecutor from pathlib import Path +from threading import Barrier from unittest import TestCase from ravendb_embedded import EmbeddedServer, ServerOptions class TestShutdown(TestCase): + def test_concurrent_start_launches_one_server(self): + with tempfile.TemporaryDirectory() as directory: + server_directory = Path(directory, "Server") + server_directory.mkdir() + start_marker = Path(directory, "starts.txt") + Path(server_directory, "Raven.Server.dll").write_text( + "import pathlib\n" + "import sys\n" + f"with pathlib.Path({str(start_marker)!r}).open('a', encoding='utf-8') as marker:\n" + " marker.write('started\\n')\n" + "print('Server available on: http://127.0.0.1:12345', flush=True)\n" + "sys.stdin.readline()\n", + encoding="utf-8", + ) + + options = ServerOptions() + options.with_external_server(str(server_directory)) + options.dot_net_path = sys.executable + options.data_directory = str(Path(directory, "data")) + options.logs_path = str(Path(directory, "logs")) + server = EmbeddedServer() + barrier = Barrier(8) + + def start(): + barrier.wait() + try: + server.start_server(options) + return "started" + except RuntimeError as error: + return str(error) + + with ThreadPoolExecutor(max_workers=8) as executor: + results = list(executor.map(lambda _: start(), range(8))) + + server.close() + + self.assertEqual(1, results.count("started")) + self.assertEqual(7, results.count("The server was already started")) + self.assertEqual(["started"], start_marker.read_text(encoding="utf-8").splitlines()) + def test_close_sends_graceful_shutdown_command(self): with tempfile.TemporaryDirectory() as directory: server_directory = Path(directory, "Server") @@ -29,6 +71,11 @@ def test_close_sends_graceful_shutdown_command(self): server = EmbeddedServer() server.start_server(options) + process = server.server_task.get_value()[1] server.close() self.assertEqual("shutdown no-confirmation", shutdown_marker.read_text(encoding="utf-8")) + self.assertTrue(process.stdin.closed) + self.assertTrue(process.stdout.closed) + self.assertTrue(process.stderr.closed) + self.assertIsNone(server._exit_handler) From 6ac7a5e52b1a60c1515e704261c70411b2a89a77 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Tue, 28 Jul 2026 18:55:01 +0200 Subject: [PATCH 10/35] RavenDB-27140 Add embedded server lifecycle controls --- README.md | 6 ++++ ravendb_embedded/embedded_server.py | 41 ++++++++++++++++++++++++ tests/test_lifecycle.py | 48 +++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 tests/test_lifecycle.py diff --git a/README.md b/README.md index 90c1c86..78a8865 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,12 @@ The certificate command must write the PFX bytes to standard output. Runnable walkthrough: [Lab 04 — secured embedded server](labs/04-embedded-secured.md). +### Server lifecycle + +Use `get_server_process_id()` to inspect the child process. `stop_server()` stops RavenDB without +disposing the `EmbeddedServer`, and `restart_server()` starts it again with the same options. +Calling `close()` stops the process and disposes all document stores. + ### Persistent data Set `data_directory` to a stable path when data should survive process restarts. Use a temporary diff --git a/ravendb_embedded/embedded_server.py b/ravendb_embedded/embedded_server.py index facb3a7..130fcd0 100644 --- a/ravendb_embedded/embedded_server.py +++ b/ravendb_embedded/embedded_server.py @@ -133,6 +133,47 @@ def get_server_uri(self) -> str: return server.get_value()[0] + def get_server_process_id(self) -> int: + with self._lifecycle_lock: + server = self._require_started_server("get_server_process_id") + return server.get_value()[1].pid + + def stop_server(self) -> None: + with self._lifecycle_lock: + server = self._require_started_server("stop_server") + self._unregister_exit_handler() + self._shutdown_server_process(server.get_value()[1]) + + def restart_server(self) -> None: + with self._lifecycle_lock: + existing_server = self._require_started_server("restart_server") + options = self._server_options + + self._unregister_exit_handler() + try: + self._shutdown_server_process(existing_server.get_value()[1]) + except Exception: + pass + + if self.server_task is not existing_server: + raise RuntimeError("The server changed while restarting it") + + restarted_server = Lazy(lambda: self._run_server(options)) + self.server_task = restarted_server + try: + restarted_server.get_value() + except Exception: + if self.server_task is restarted_server: + self.server_task = None + self._unregister_exit_handler() + raise + + def _require_started_server(self, operation: str) -> Lazy[Tuple[str, subprocess.Popen]]: + server = self.server_task + if self._server_options is None or server is None or not server.created: + raise RuntimeError(f"Cannot call {operation}() before calling start_server()") + return server + def _shutdown_server_process(self, process: subprocess.Popen) -> None: if not process: return diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py new file mode 100644 index 0000000..77cd4e7 --- /dev/null +++ b/tests/test_lifecycle.py @@ -0,0 +1,48 @@ +import tempfile +from pathlib import Path +from unittest import TestCase + +from ravendb_embedded import CopyServerFromNugetProvider, EmbeddedServer, ServerOptions + + +class TestLifecycle(TestCase): + def test_lifecycle_methods_require_a_started_server(self): + server = EmbeddedServer() + + for operation in ( + server.get_server_process_id, + server.stop_server, + server.restart_server, + ): + with self.subTest(operation=operation.__name__): + with self.assertRaisesRegex( + RuntimeError, + rf"Cannot call {operation.__name__}\(\) before calling start_server\(\)", + ): + operation() + + def test_stop_and_restart_real_server(self): + with tempfile.TemporaryDirectory() as directory: + options = ServerOptions() + options.data_directory = str(Path(directory, "RavenDB")) + options.logs_path = str(Path(directory, "Logs")) + options.provider = CopyServerFromNugetProvider() + + with EmbeddedServer() as server: + server.start_server(options) + first_process = server.server_task.get_value()[1] + first_pid = server.get_server_process_id() + + server.stop_server() + + self.assertEqual(first_pid, first_process.pid) + self.assertIsNotNone(first_process.poll()) + + server.restart_server() + second_pid = server.get_server_process_id() + + self.assertNotEqual(first_pid, second_pid) + with server.get_document_store("Lifecycle") as store: + with store.open_session() as session: + session.store({"Status": "restarted"}, "lifecycle/1") + session.save_changes() From 85082228cb678891fa10ad7b35ce0a2a21a3ae22 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Tue, 28 Jul 2026 18:56:28 +0200 Subject: [PATCH 11/35] RavenDB-27140 Report embedded server process exits --- README.md | 11 +++++ ravendb_embedded/__init__.py | 2 +- ravendb_embedded/embedded_server.py | 52 ++++++++++++++++++++ tests/test_process_exit.py | 75 +++++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 tests/test_process_exit.py diff --git a/README.md b/README.md index 78a8865..095bf52 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,17 @@ Use `get_server_process_id()` to inspect the child process. `stop_server()` stop disposing the `EmbeddedServer`, and `restart_server()` starts it again with the same options. Calling `close()` stops the process and disposes all document stores. +Register a process-exit callback when the application needs to observe server failures: + +```python +server.add_server_process_exited( + lambda event: print(event.process_id, event.exit_code, event.expected) +) +``` + +Callbacks run on a background thread. `event.expected` is `True` for exits caused by +`stop_server()`, `restart_server()`, or `close()`, and `False` when the child exits unexpectedly. + ### Persistent data Set `data_directory` to a stable path when data should survive process restarts. Use a temporary diff --git a/ravendb_embedded/__init__.py b/ravendb_embedded/__init__.py index faac0e1..c14de0e 100644 --- a/ravendb_embedded/__init__.py +++ b/ravendb_embedded/__init__.py @@ -1,4 +1,4 @@ -from ravendb_embedded.embedded_server import EmbeddedServer +from ravendb_embedded.embedded_server import EmbeddedServer, ServerProcessExitedEvent from ravendb_embedded.options import DatabaseOptions, ServerOptions, SecurityOptions from ravendb_embedded.on_demand import ensure_server from ravendb_embedded.provide import ( diff --git a/ravendb_embedded/embedded_server.py b/ravendb_embedded/embedded_server.py index 130fcd0..38f7baa 100644 --- a/ravendb_embedded/embedded_server.py +++ b/ravendb_embedded/embedded_server.py @@ -6,6 +6,7 @@ import shutil import subprocess import time +from dataclasses import dataclass from datetime import timedelta from typing import Optional, List, Callable, Tuple, Generic, TypeVar, Dict, IO from threading import Lock, RLock, Thread @@ -21,6 +22,13 @@ _T = TypeVar("_T") +@dataclass(frozen=True) +class ServerProcessExitedEvent: + process_id: int + exit_code: int + expected: bool + + class EmbeddedServer: END_OF_STREAM_MARKER = "$$END_OF_STREAM$$" @@ -30,6 +38,10 @@ def __init__(self): self._server_options: Optional[ServerOptions] = None self._lifecycle_lock = RLock() self._exit_handler: Optional[Callable[[], None]] = None + self._process_exit_lock = RLock() + self._process_exit_callbacks: List[Callable[[ServerProcessExitedEvent], None]] = [] + self._watched_processes = set() + self._expected_process_exits = set() self.document_stores = {} self._document_stores_lock = RLock() self.client_pem_certificate_path: Optional[str] = None @@ -48,6 +60,18 @@ def _log_debug(self, message: str) -> None: if not self.logger.disabled and self.logger.isEnabledFor(logging.DEBUG): self.logger.log(logging.DEBUG, message) + def add_server_process_exited(self, callback: Callable[[ServerProcessExitedEvent], None]) -> None: + if not callable(callback): + raise ValueError("callback must be callable") + with self._process_exit_lock: + if callback not in self._process_exit_callbacks: + self._process_exit_callbacks.append(callback) + + def remove_server_process_exited(self, callback: Callable[[ServerProcessExitedEvent], None]) -> None: + with self._process_exit_lock: + if callback in self._process_exit_callbacks: + self._process_exit_callbacks.remove(callback) + def start_server(self, options_param: ServerOptions = None) -> None: options = options_param or ServerOptions() @@ -182,6 +206,7 @@ def _shutdown_server_process(self, process: subprocess.Popen) -> None: if process.poll() is not None: return + self._mark_process_exit_expected(process) graceful_timeout = (self._graceful_shutdown_timeout or timedelta(seconds=30)).total_seconds() kill_timeout = (self._process_kill_timeout or timedelta(seconds=5)).total_seconds() @@ -239,6 +264,32 @@ def _register_exit_handler(self, process: subprocess.Popen) -> None: self._exit_handler = lambda: self._shutdown_server_process(process) atexit.register(self._exit_handler) + def _watch_server_process(self, process: subprocess.Popen) -> None: + with self._process_exit_lock: + self._watched_processes.add(process.pid) + + def watch(): + exit_code = process.wait() + with self._process_exit_lock: + expected = process.pid in self._expected_process_exits + self._expected_process_exits.discard(process.pid) + self._watched_processes.discard(process.pid) + callbacks = list(self._process_exit_callbacks) + + event = ServerProcessExitedEvent(process.pid, exit_code, expected) + for callback in callbacks: + try: + callback(event) + except Exception as error: + self._log_debug(f"Server process exit callback failed. Error: {error}") + + Thread(target=watch, daemon=True, name=f"ravendb-process-{process.pid}").start() + + def _mark_process_exit_expected(self, process: subprocess.Popen) -> None: + with self._process_exit_lock: + if process.pid in self._watched_processes: + self._expected_process_exits.add(process.pid) + def _unregister_exit_handler(self) -> None: if self._exit_handler is None: return @@ -278,6 +329,7 @@ def _run_server(self, options: ServerOptions) -> Tuple[str, subprocess.Popen]: raise RuntimeError(self.build_startup_exception_message(output_string, error_string, process)) self._register_exit_handler(process) + self._watch_server_process(process) return url_ref["value"], process @staticmethod diff --git a/tests/test_process_exit.py b/tests/test_process_exit.py new file mode 100644 index 0000000..c86c1da --- /dev/null +++ b/tests/test_process_exit.py @@ -0,0 +1,75 @@ +import sys +import tempfile +from pathlib import Path +from threading import Event +from unittest import TestCase + +from ravendb_embedded import EmbeddedServer, ServerOptions + + +class TestProcessExit(TestCase): + @staticmethod + def server_options(directory, script): + server_directory = Path(directory, "Server") + server_directory.mkdir() + Path(server_directory, "Raven.Server.dll").write_text(script, encoding="utf-8") + + options = ServerOptions() + options.with_external_server(str(server_directory)) + options.dot_net_path = sys.executable + options.data_directory = str(Path(directory, "data")) + options.logs_path = str(Path(directory, "logs")) + return options + + def test_reports_unexpected_process_exit(self): + with tempfile.TemporaryDirectory() as directory: + options = self.server_options( + directory, + "import sys\n" + "print('Server available on: http://127.0.0.1:12345', flush=True)\n" + "raise SystemExit(23)\n", + ) + callback_finished = Event() + events = [] + + with EmbeddedServer() as server: + def on_exit(event): + events.append(event) + callback_finished.set() + + server.add_server_process_exited(on_exit) + server.start_server(options) + process_id = server.get_server_process_id() + + self.assertTrue(callback_finished.wait(10)) + + self.assertEqual(1, len(events)) + self.assertEqual(process_id, events[0].process_id) + self.assertEqual(23, events[0].exit_code) + self.assertFalse(events[0].expected) + + def test_reports_expected_process_exit(self): + with tempfile.TemporaryDirectory() as directory: + options = self.server_options( + directory, + "import sys\n" + "print('Server available on: http://127.0.0.1:12345', flush=True)\n" + "sys.stdin.readline()\n", + ) + callback_finished = Event() + events = [] + + with EmbeddedServer() as server: + server.add_server_process_exited( + lambda event: (events.append(event), callback_finished.set()) + ) + server.start_server(options) + process_id = server.get_server_process_id() + server.stop_server() + + self.assertTrue(callback_finished.wait(10)) + + self.assertEqual(1, len(events)) + self.assertEqual(process_id, events[0].process_id) + self.assertEqual(0, events[0].exit_code) + self.assertTrue(events[0].expected) From 766c44f6b5b6302ab0df5bda011076d52454bdb5 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 29 Jul 2026 13:42:53 +0200 Subject: [PATCH 12/35] RavenDB-27140 Drain server output streams concurrently --- ravendb_embedded/embedded_server.py | 159 +++++++++++----------------- tests/test_startup_errors.py | 35 ++++++ 2 files changed, 99 insertions(+), 95 deletions(-) diff --git a/ravendb_embedded/embedded_server.py b/ravendb_embedded/embedded_server.py index 38f7baa..0edf8fe 100644 --- a/ravendb_embedded/embedded_server.py +++ b/ravendb_embedded/embedded_server.py @@ -8,14 +8,13 @@ import time from dataclasses import dataclass from datetime import timedelta -from typing import Optional, List, Callable, Tuple, Generic, TypeVar, Dict, IO -from threading import Lock, RLock, Thread +from typing import Optional, List, Callable, Tuple, Generic, TypeVar +from threading import Event, Lock, RLock, Thread from queue import Queue import webbrowser from ravendb import DocumentStore, CreateDatabaseOperation -from ravendb.exceptions.raven_exceptions import ConcurrencyException, RavenException -from ravendb.tools.utils import Stopwatch +from ravendb.exceptions.raven_exceptions import ConcurrencyException from ravendb_embedded.options import ServerOptions, DatabaseOptions from ravendb_embedded.raven_server_runner import RavenServerRunner @@ -30,8 +29,6 @@ class ServerProcessExitedEvent: class EmbeddedServer: - END_OF_STREAM_MARKER = "$$END_OF_STREAM$$" - # singleton def __init__(self): self.server_task: Optional[Lazy[Tuple[str, subprocess.Popen]]] = None @@ -313,24 +310,72 @@ def _run_server(self, options: ServerOptions) -> Tuple[str, subprocess.Popen]: self._log_debug("Starting global server") - url_ref: Dict[str, Optional[str]] = {"value": None} - startup_duration = Stopwatch.create_started() - - output_string = self.read_output( - process.stdout, - startup_duration, - options, - lambda line, builder: self.online(line, builder, url_ref, process, startup_duration, options), - ) - - if url_ref["value"] is None: - error_string = self.read_output(process.stderr, Stopwatch.create_started(), options, None) + server_url, output_string, error_string = self._wait_for_server_start(process, options) + if server_url is None: self._shutdown_server_process(process) raise RuntimeError(self.build_startup_exception_message(output_string, error_string, process)) self._register_exit_handler(process) self._watch_server_process(process) - return url_ref["value"], process + return server_url, process + + def _wait_for_server_start( + self, + process: subprocess.Popen, + options: ServerOptions, + ) -> Tuple[Optional[str], str, str]: + output_events: Queue[Tuple[str, Optional[str]]] = Queue() + startup_complete = Event() + + def read_stream(stream_name, stream): + try: + for raw_line in iter(stream.readline, b""): + line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n") + if not startup_complete.is_set(): + output_events.put((stream_name, line)) + except Exception as error: + if not startup_complete.is_set(): + output_events.put((stream_name, f"Unable to read process output: {error}")) + finally: + if not startup_complete.is_set(): + output_events.put((stream_name, None)) + + Thread(target=read_stream, args=("stdout", process.stdout), daemon=True).start() + Thread(target=read_stream, args=("stderr", process.stderr), daemon=True).start() + + stdout_lines = [] + stderr_lines = [] + ended_streams = set() + deadline = time.monotonic() + options.max_server_startup_time_duration.total_seconds() + prefix = "Server available on: " + + try: + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + return None, self._join_output(stdout_lines), self._join_output(stderr_lines) + + try: + stream_name, line = output_events.get(timeout=min(0.1, remaining)) + except queue.Empty: + continue + + if line is None: + ended_streams.add(stream_name) + if len(ended_streams) == 2: + return None, self._join_output(stdout_lines), self._join_output(stderr_lines) + continue + + lines = stdout_lines if stream_name == "stdout" else stderr_lines + lines.append(line) + if stream_name == "stdout" and line.startswith(prefix): + return line[len(prefix) :], self._join_output(stdout_lines), self._join_output(stderr_lines) + finally: + startup_complete.set() + + @staticmethod + def _join_output(lines: List[str]) -> str: + return os.linesep.join(lines) + (os.linesep if lines else "") @staticmethod def build_startup_exception_message(output_string: str, error_string: str, process: subprocess.Popen) -> str: @@ -357,82 +402,6 @@ def build_startup_exception_message(output_string: str, error_string: str, proce sb.append("Check your ServerOptions and host dependencies, or run the command manually to see detailed error.") return "".join(sb) - def online( - self, - line: str, - builder: List[str], - url_ref: Dict[str, Optional[str]], - process: subprocess.Popen, - startup_duration: Stopwatch, - options: ServerOptions, - ): - if line is None: - error_string = self.read_output(process.stderr, Stopwatch.create_started(), options, None) - self._shutdown_server_process(process) - raise RuntimeError(self.build_startup_exception_message("".join(builder), error_string, process)) - - prefix = "Server available on: " - if line.startswith(prefix): - url_ref["value"] = line[len(prefix) :] - return True - - return False - - def read_output( - self, - output: IO, - startup_duration: Stopwatch, - options: ServerOptions, - online: Optional[Callable[[str, List[str]], bool]], - ): - def read_output_line() -> Optional[str]: - while True: - try: - line_ = output_queue.get_nowait() - return line_ - except queue.Empty: - if options.max_server_startup_time_duration - startup_duration.elapsed() <= timedelta(seconds=0): - return None - time.sleep(1) - - def output_reader(): - try: - for line_ in iter(output.readline, b""): - output_queue.put(line_.decode("utf-8").strip()) - output_queue.put(self.END_OF_STREAM_MARKER) - except Exception as e: - raise RavenException("Unable to read server output") from e - - output_queue: Queue[str] = Queue() - output_thread = Thread(target=output_reader, daemon=True) - output_thread.start() - - sb = [] - - while True: - line = read_output_line() - - if options.max_server_startup_time_duration < startup_duration.elapsed(): - return "".join(sb) - - if line is None: - break - - if line == self.END_OF_STREAM_MARKER: - break - - sb.append(line) - sb.append(os.linesep) - - should_stop = False - if online is not None: - should_stop = online(line, sb) - - if should_stop: - break - - return "".join(sb) - def open_studio_in_browser(self): server_url = self.get_server_uri() diff --git a/tests/test_startup_errors.py b/tests/test_startup_errors.py index 400730b..3c69d1d 100644 --- a/tests/test_startup_errors.py +++ b/tests/test_startup_errors.py @@ -8,12 +8,45 @@ class TestStartupErrors(TestCase): + def test_stderr_is_drained_before_and_after_server_is_online(self): + with tempfile.TemporaryDirectory() as directory: + server_directory = Path(directory, "Server") + server_directory.mkdir() + shutdown_marker = Path(directory, "shutdown.txt") + Path(server_directory, "Raven.Server.dll").write_text( + "import pathlib\n" + "import sys\n" + "payload = 'x' * (1024 * 1024)\n" + "sys.stderr.write(payload)\n" + "sys.stderr.flush()\n" + "print('Server available on: http://127.0.0.1:12345', flush=True)\n" + "sys.stderr.write(payload)\n" + "sys.stderr.flush()\n" + "command = sys.stdin.readline().strip()\n" + f"pathlib.Path({str(shutdown_marker)!r}).write_text(command, encoding='utf-8')\n", + encoding="utf-8", + ) + + options = ServerOptions() + options.with_external_server(str(server_directory)) + options.dot_net_path = sys.executable + options.data_directory = str(Path(directory, "data")) + options.logs_path = str(Path(directory, "logs")) + options.max_server_startup_time_duration = timedelta(seconds=10) + + with EmbeddedServer() as server: + server.start_server(options) + + self.assertEqual("shutdown no-confirmation", shutdown_marker.read_text(encoding="utf-8")) + def test_failed_server_process_includes_stderr(self): with tempfile.TemporaryDirectory() as directory: server_directory = Path(directory, "Server") server_directory.mkdir() Path(server_directory, "Raven.Server.dll").write_text( "import sys\n" + "sys.stdout.write('intentional stdout before failure\\n')\n" + "sys.stdout.flush()\n" "sys.stderr.write('intentional startup failure from child process\\n')\n" "raise SystemExit(23)\n", encoding="utf-8", @@ -34,3 +67,5 @@ def test_failed_server_process_includes_stderr(self): self.assertIn("Unable to start the RavenDB Server", message) self.assertIn("Error:", message) self.assertIn("intentional startup failure from child process", message) + self.assertIn("Output:", message) + self.assertIn("intentional stdout before failure", message) From 5d021bfe1a2326eec36ae887476f106009ecd49d Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 29 Jul 2026 13:43:50 +0200 Subject: [PATCH 13/35] RavenDB-27140 Distinguish server startup timeouts --- README.md | 3 ++ ravendb_embedded/__init__.py | 7 ++++- ravendb_embedded/embedded_server.py | 43 +++++++++++++++++++++++------ tests/test_startup_errors.py | 29 +++++++++++++++++-- 4 files changed, 71 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 095bf52..0981ebc 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,9 @@ Create a `ServerOptions` instance before starting the server: - `process_kill_timeout`: how long to wait for the process after terminating or killing it. - `max_server_startup_time_duration`: maximum server startup time. +Startup failures raise `ServerStartupError`. When the configured startup duration expires, the +more specific `ServerStartupTimeoutError` is raised. + ### HTTPS and client certificates Use `ServerOptions.secured()` to start RavenDB over HTTPS: diff --git a/ravendb_embedded/__init__.py b/ravendb_embedded/__init__.py index c14de0e..a72ac5e 100644 --- a/ravendb_embedded/__init__.py +++ b/ravendb_embedded/__init__.py @@ -1,4 +1,9 @@ -from ravendb_embedded.embedded_server import EmbeddedServer, ServerProcessExitedEvent +from ravendb_embedded.embedded_server import ( + EmbeddedServer, + ServerProcessExitedEvent, + ServerStartupError, + ServerStartupTimeoutError, +) from ravendb_embedded.options import DatabaseOptions, ServerOptions, SecurityOptions from ravendb_embedded.on_demand import ensure_server from ravendb_embedded.provide import ( diff --git a/ravendb_embedded/embedded_server.py b/ravendb_embedded/embedded_server.py index 0edf8fe..ec51c14 100644 --- a/ravendb_embedded/embedded_server.py +++ b/ravendb_embedded/embedded_server.py @@ -21,6 +21,14 @@ _T = TypeVar("_T") +class ServerStartupError(RuntimeError): + pass + + +class ServerStartupTimeoutError(ServerStartupError): + pass + + @dataclass(frozen=True) class ServerProcessExitedEvent: process_id: int @@ -310,10 +318,19 @@ def _run_server(self, options: ServerOptions) -> Tuple[str, subprocess.Popen]: self._log_debug("Starting global server") - server_url, output_string, error_string = self._wait_for_server_start(process, options) + server_url, output_string, error_string, timed_out = self._wait_for_server_start(process, options) if server_url is None: self._shutdown_server_process(process) - raise RuntimeError(self.build_startup_exception_message(output_string, error_string, process)) + message = self.build_startup_exception_message( + output_string, + error_string, + process, + timed_out=timed_out, + startup_timeout=options.max_server_startup_time_duration, + ) + if timed_out: + raise ServerStartupTimeoutError(message) + raise ServerStartupError(message) self._register_exit_handler(process) self._watch_server_process(process) @@ -323,7 +340,7 @@ def _wait_for_server_start( self, process: subprocess.Popen, options: ServerOptions, - ) -> Tuple[Optional[str], str, str]: + ) -> Tuple[Optional[str], str, str, bool]: output_events: Queue[Tuple[str, Optional[str]]] = Queue() startup_complete = Event() @@ -353,7 +370,7 @@ def read_stream(stream_name, stream): while True: remaining = deadline - time.monotonic() if remaining <= 0: - return None, self._join_output(stdout_lines), self._join_output(stderr_lines) + return None, self._join_output(stdout_lines), self._join_output(stderr_lines), True try: stream_name, line = output_events.get(timeout=min(0.1, remaining)) @@ -363,13 +380,13 @@ def read_stream(stream_name, stream): if line is None: ended_streams.add(stream_name) if len(ended_streams) == 2: - return None, self._join_output(stdout_lines), self._join_output(stderr_lines) + return None, self._join_output(stdout_lines), self._join_output(stderr_lines), False continue lines = stdout_lines if stream_name == "stdout" else stderr_lines lines.append(line) if stream_name == "stdout" and line.startswith(prefix): - return line[len(prefix) :], self._join_output(stdout_lines), self._join_output(stderr_lines) + return line[len(prefix) :], self._join_output(stdout_lines), self._join_output(stderr_lines), False finally: startup_complete.set() @@ -378,8 +395,18 @@ def _join_output(lines: List[str]) -> str: return os.linesep.join(lines) + (os.linesep if lines else "") @staticmethod - def build_startup_exception_message(output_string: str, error_string: str, process: subprocess.Popen) -> str: - sb = ["Unable to start the RavenDB Server", os.linesep] + def build_startup_exception_message( + output_string: str, + error_string: str, + process: subprocess.Popen, + timed_out: bool = False, + startup_timeout: timedelta = None, + ) -> str: + if timed_out: + heading = f"Server failed to start in {startup_timeout.total_seconds()} seconds." + else: + heading = "Unable to start the RavenDB Server" + sb = [heading, os.linesep] if process.args: sb.append("Command:") diff --git a/tests/test_startup_errors.py b/tests/test_startup_errors.py index 3c69d1d..c245814 100644 --- a/tests/test_startup_errors.py +++ b/tests/test_startup_errors.py @@ -4,10 +4,35 @@ from pathlib import Path from unittest import TestCase -from ravendb_embedded import EmbeddedServer, ServerOptions +from ravendb_embedded import EmbeddedServer, ServerOptions, ServerStartupError, ServerStartupTimeoutError class TestStartupErrors(TestCase): + def test_startup_timeout_has_a_distinct_exception(self): + with tempfile.TemporaryDirectory() as directory: + server_directory = Path(directory, "Server") + server_directory.mkdir() + Path(server_directory, "Raven.Server.dll").write_text( + "import time\n" + "time.sleep(60)\n", + encoding="utf-8", + ) + + options = ServerOptions() + options.with_external_server(str(server_directory)) + options.dot_net_path = sys.executable + options.data_directory = str(Path(directory, "data")) + options.logs_path = str(Path(directory, "logs")) + options.max_server_startup_time_duration = timedelta(milliseconds=200) + options.graceful_shutdown_timeout = timedelta(milliseconds=100) + options.process_kill_timeout = timedelta(seconds=1) + + with EmbeddedServer() as server: + with self.assertRaises(ServerStartupTimeoutError) as context: + server.start_server(options) + + self.assertIn("Server failed to start in 0.2 seconds.", str(context.exception)) + def test_stderr_is_drained_before_and_after_server_is_online(self): with tempfile.TemporaryDirectory() as directory: server_directory = Path(directory, "Server") @@ -60,7 +85,7 @@ def test_failed_server_process_includes_stderr(self): options.max_server_startup_time_duration = timedelta(seconds=10) with EmbeddedServer() as server: - with self.assertRaises(RuntimeError) as context: + with self.assertRaises(ServerStartupError) as context: server.start_server(options) message = str(context.exception) From 9b83f779f0b9821a0756c744020b85b4682af311 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 29 Jul 2026 13:44:31 +0200 Subject: [PATCH 14/35] RavenDB-27140 Integrate embedded logs with Python logging --- ravendb_embedded/embedded_server.py | 2 +- tests/test_diagnostics.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 tests/test_diagnostics.py diff --git a/ravendb_embedded/embedded_server.py b/ravendb_embedded/embedded_server.py index ec51c14..4adb89b 100644 --- a/ravendb_embedded/embedded_server.py +++ b/ravendb_embedded/embedded_server.py @@ -53,7 +53,7 @@ def __init__(self): self.trust_store_path: Optional[str] = None self._graceful_shutdown_timeout: Optional[timedelta] = None self._process_kill_timeout: Optional[timedelta] = None - self.logger = logging.Logger(self.__class__.__name__, logging.DEBUG) + self.logger = logging.getLogger(f"ravendb_embedded.{self.__class__.__name__}") def __enter__(self): return self diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 0000000..a309fc6 --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,15 @@ +import logging +from unittest import TestCase + +from ravendb_embedded import EmbeddedServer + + +class TestDiagnostics(TestCase): + def test_embedded_logging_uses_python_logging_hierarchy(self): + server = EmbeddedServer() + + with self.assertLogs("ravendb_embedded.EmbeddedServer", logging.DEBUG) as captured: + server._log_debug("embedded diagnostic") + + self.assertEqual("ravendb_embedded.EmbeddedServer", server.logger.name) + self.assertIn("embedded diagnostic", captured.output[0]) From 80240ab885c64abcf8067e0aab7ad01e63a2fcd1 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 29 Jul 2026 13:44:49 +0200 Subject: [PATCH 15/35] RavenDB-27140 Open the canonical RavenDB Studio URL --- ravendb_embedded/embedded_server.py | 8 ++++++-- tests/test_diagnostics.py | 6 ++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/ravendb_embedded/embedded_server.py b/ravendb_embedded/embedded_server.py index 4adb89b..afd7cdd 100644 --- a/ravendb_embedded/embedded_server.py +++ b/ravendb_embedded/embedded_server.py @@ -430,13 +430,17 @@ def build_startup_exception_message( return "".join(sb) def open_studio_in_browser(self): - server_url = self.get_server_uri() + studio_url = self._get_studio_url(self.get_server_uri()) try: - webbrowser.open(server_url) + webbrowser.open(studio_url) except Exception as e: raise RuntimeError(e) + @staticmethod + def _get_studio_url(server_url: str) -> str: + return f"{server_url.rstrip('/')}/studio/index.html?disableAnalytics=true" + def close(self): with self._lifecycle_lock: lazy = self.server_task diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index a309fc6..7143019 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -5,6 +5,12 @@ class TestDiagnostics(TestCase): + def test_studio_url_disables_analytics(self): + self.assertEqual( + "http://127.0.0.1:8080/studio/index.html?disableAnalytics=true", + EmbeddedServer._get_studio_url("http://127.0.0.1:8080/"), + ) + def test_embedded_logging_uses_python_logging_hierarchy(self): server = EmbeddedServer() From f6f7da9a238e6aa68c40f5a6f754eb851610e0a1 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 29 Jul 2026 13:47:40 +0200 Subject: [PATCH 16/35] RavenDB-27140 Detect the bundled server runtime automatically --- README.md | 4 +- ravendb_embedded/options.py | 2 +- ravendb_embedded/raven_server_runner.py | 28 +++++++--- .../runtime_framework_version_matcher.py | 56 ++++++++++++------- tests/test_process_exit.py | 1 + .../test_runtime_framework_version_matcher.py | 24 +++++++- tests/test_shutdown.py | 2 + tests/test_startup_errors.py | 3 + 8 files changed, 86 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 0981ebc..57e51d4 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,9 @@ Create a `ServerOptions` instance before starting the server: - `dot_net_path`: path to `dotnet` for bundled mode when it is not on `PATH`. - `command_line_args`: additional [RavenDB server arguments](https://ravendb.net/docs/article-page/latest/csharp/server/configuration/command-line-arguments). -- `framework_version`: an exact .NET runtime version for advanced bundled-server setups. +- `framework_version`: defaults to `auto`, which reads the server runtime configuration and selects + a compatible installed .NET patch. Set an exact version for advanced setups, or `None`/`""` to + use the dotnet host's normal roll-forward behavior. - `graceful_shutdown_timeout`: how long to wait before terminating the child process. - `process_kill_timeout`: how long to wait for the process after terminating or killing it. - `max_server_startup_time_duration`: maximum server startup time. diff --git a/ravendb_embedded/options.py b/ravendb_embedded/options.py index b70cc10..4b0975b 100644 --- a/ravendb_embedded/options.py +++ b/ravendb_embedded/options.py @@ -43,7 +43,7 @@ class ServerOptions: DEFAULT_SERVER_LOCATION = os.path.join(BASE_MODULE_DIRECTORY, CopyServerFromNugetProvider.SERVER_FILES) def __init__(self): - self.framework_version: Optional[str] = "" + self.framework_version: Optional[str] = "auto" self.logs_path: str = self.BASE_MODULE_DIRECTORY + "/RavenDB/Logs" self.data_directory: str = self.BASE_MODULE_DIRECTORY + "/RavenDB" self.provider: ProvideRavenDBServer = CopyServerFromNugetProvider() diff --git a/ravendb_embedded/raven_server_runner.py b/ravendb_embedded/raven_server_runner.py index 9c34018..8c4f759 100644 --- a/ravendb_embedded/raven_server_runner.py +++ b/ravendb_embedded/raven_server_runner.py @@ -95,17 +95,29 @@ def run(options: ServerOptions) -> subprocess.Popen: if not is_sfa: command_line_args.insert(0, options.dot_net_path) - if options.framework_version: - framework_version = RuntimeFrameworkVersionMatcher.match(options) + requested_framework = options.framework_version + if requested_framework == RuntimeFrameworkVersionMatcher.AUTO: + requested_framework = RuntimeFrameworkVersionMatcher.required_framework_version(server_file_path) + + if requested_framework: + framework_version = RuntimeFrameworkVersionMatcher.match(options, requested_framework) command_line_args.insert(1, framework_version) command_line_args.insert(1, "--fx-version") - process_builder = subprocess.Popen( - command_line_args, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) + try: + process_builder = subprocess.Popen( + command_line_args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except FileNotFoundError as error: + if is_sfa: + raise + raise RuntimeError( + f"Unable to execute the .NET host '{options.dot_net_path}'. Install the required .NET runtime, " + "set ServerOptions.dot_net_path, or use with_auto_downloaded_server() to run without system .NET." + ) from error process = process_builder return process diff --git a/ravendb_embedded/runtime_framework_version_matcher.py b/ravendb_embedded/runtime_framework_version_matcher.py index 08dc2e5..e2e0175 100644 --- a/ravendb_embedded/runtime_framework_version_matcher.py +++ b/ravendb_embedded/runtime_framework_version_matcher.py @@ -1,6 +1,8 @@ from __future__ import annotations +import json import subprocess from enum import Enum +from pathlib import Path from typing import Optional, Tuple, List from ravendb_embedded.options import ServerOptions @@ -12,15 +14,17 @@ class MatchingType(Enum): class RuntimeFrameworkVersionMatcher: + AUTO = "auto" WILDCARD = "x" GREATER_OR_EQUAL = "+" @classmethod - def match(cls, options: ServerOptions) -> str: - if not cls.needs_match(options): - return options.framework_version + def match(cls, options: ServerOptions, framework_version: str = None) -> str: + framework_version = options.framework_version if framework_version is None else framework_version + if not cls.needs_match(options, framework_version): + return framework_version - runtime = RuntimeFrameworkVersion(options.framework_version) + runtime = RuntimeFrameworkVersion(framework_version) runtimes = cls.get_framework_versions(options) return cls.match_runtime(runtime, runtimes) @@ -39,22 +43,37 @@ def match_runtime(runtime: RuntimeFrameworkVersion, runtimes: List[RuntimeFramew ) @classmethod - def needs_match(cls, options: ServerOptions) -> bool: - if not options or not options.framework_version: + def needs_match(cls, options: ServerOptions, framework_version: str = None) -> bool: + framework_version = options.framework_version if framework_version is None else framework_version + if not options or not framework_version: return False - framework_version = options.framework_version.lower() + framework_version = framework_version.lower() if cls.WILDCARD not in framework_version and cls.GREATER_OR_EQUAL not in framework_version: return False return True + @classmethod + def required_framework_version(cls, server_file_path: str) -> str: + runtime_config = Path(server_file_path).with_suffix(".runtimeconfig.json") + try: + config = json.loads(runtime_config.read_text(encoding="utf-8")) + frameworks = config["runtimeOptions"]["frameworks"] + version = next(item["version"] for item in frameworks if item["name"] == "Microsoft.NETCore.App") + except (OSError, ValueError, KeyError, StopIteration, TypeError) as error: + raise RuntimeError( + f"Unable to determine the required .NET runtime from '{runtime_config}'. " + "Set ServerOptions.framework_version explicitly to override automatic detection." + ) from error + return f"{version}{cls.GREATER_OR_EQUAL}" + @staticmethod def get_framework_versions(options: ServerOptions): if not options.dot_net_path: raise RuntimeError("Dotnet path is not provided.") - process_command = [options.dot_net_path, "--info"] + process_command = [options.dot_net_path, "--list-runtimes"] runtimes = [] process = None @@ -65,27 +84,22 @@ def get_framework_versions(options: ServerOptions): stderr=subprocess.PIPE, text=True, ) as process: - inside_runtimes = False - runtime_lines = [] - for line in process.stdout: - line = line.strip() - if line.startswith(".NET runtimes installed:") or line.startswith(".NET Core runtimes installed:"): - inside_runtimes = True + values = line.strip().split() + if not values or values[0] != "Microsoft.NETCore.App": continue - if inside_runtimes and line.startswith("Microsoft.NETCore.App"): - runtime_lines.append(line) - - for runtime_line in runtime_lines: - values = runtime_line.split(" ") if len(values) < 2: raise RuntimeError( - f"Invalid runtime line. Expected 'Microsoft.NETCore.App x.x.x', but was '{runtime_line}'" + f"Invalid runtime line. Expected 'Microsoft.NETCore.App x.x.x', but was '{line.strip()}'" ) runtimes.append(RuntimeFrameworkVersion(values[1])) except Exception as e: - raise RuntimeError("Unable to execute dotnet to retrieve list of installed runtimes") from e + raise RuntimeError( + f"Unable to execute '{options.dot_net_path}' to retrieve installed .NET runtimes. " + "Install the required .NET runtime, set ServerOptions.dot_net_path, " + "or use with_auto_downloaded_server() to run without system .NET." + ) from e finally: if process is not None and process.poll() is None: process.kill() diff --git a/tests/test_process_exit.py b/tests/test_process_exit.py index c86c1da..51d03b2 100644 --- a/tests/test_process_exit.py +++ b/tests/test_process_exit.py @@ -17,6 +17,7 @@ def server_options(directory, script): options = ServerOptions() options.with_external_server(str(server_directory)) options.dot_net_path = sys.executable + options.framework_version = "" options.data_directory = str(Path(directory, "data")) options.logs_path = str(Path(directory, "logs")) return options diff --git a/tests/test_runtime_framework_version_matcher.py b/tests/test_runtime_framework_version_matcher.py index fe988a2..9cb2674 100644 --- a/tests/test_runtime_framework_version_matcher.py +++ b/tests/test_runtime_framework_version_matcher.py @@ -11,8 +11,7 @@ class TestRuntimeFrameworkVersionMatcher(TestCase): def test_match_1(self): - # Default framework version is unenforced (empty); match() returns it unchanged. - self.assertFalse(ServerOptions.INSTANCE().framework_version) + self.assertEqual("auto", ServerOptions.INSTANCE().framework_version) options = ServerOptions() @@ -112,11 +111,30 @@ def test_missing_dotnet_preserves_execution_error(self): RuntimeFrameworkVersionMatcher.get_framework_versions(options) self.assertEqual( - "Unable to execute dotnet to retrieve list of installed runtimes", + f"Unable to execute '{options.dot_net_path}' to retrieve installed .NET runtimes. " + "Install the required .NET runtime, set ServerOptions.dot_net_path, " + "or use with_auto_downloaded_server() to run without system .NET.", str(context.exception), ) self.assertIsInstance(context.exception.__cause__, OSError) + def test_reads_required_runtime_from_server_config(self): + with tempfile.TemporaryDirectory() as directory: + server = Path(directory, "Raven.Server.dll") + server.touch() + Path(directory, "Raven.Server.runtimeconfig.json").write_text( + '{"runtimeOptions":{"frameworks":[' + '{"name":"Microsoft.NETCore.App","version":"10.0.9"},' + '{"name":"Microsoft.AspNetCore.App","version":"10.0.9"}' + "]}}", + encoding="utf-8", + ) + + self.assertEqual( + "10.0.9+", + RuntimeFrameworkVersionMatcher.required_framework_version(str(server)), + ) + def get_runtimes(self): result = [ RuntimeFrameworkVersion("2.1.3"), diff --git a/tests/test_shutdown.py b/tests/test_shutdown.py index dbb39a3..eb75373 100644 --- a/tests/test_shutdown.py +++ b/tests/test_shutdown.py @@ -27,6 +27,7 @@ def test_concurrent_start_launches_one_server(self): options = ServerOptions() options.with_external_server(str(server_directory)) options.dot_net_path = sys.executable + options.framework_version = "" options.data_directory = str(Path(directory, "data")) options.logs_path = str(Path(directory, "logs")) server = EmbeddedServer() @@ -66,6 +67,7 @@ def test_close_sends_graceful_shutdown_command(self): options = ServerOptions() options.with_external_server(str(server_directory)) options.dot_net_path = sys.executable + options.framework_version = "" options.data_directory = str(Path(directory, "data")) options.logs_path = str(Path(directory, "logs")) diff --git a/tests/test_startup_errors.py b/tests/test_startup_errors.py index c245814..b5acf22 100644 --- a/tests/test_startup_errors.py +++ b/tests/test_startup_errors.py @@ -21,6 +21,7 @@ def test_startup_timeout_has_a_distinct_exception(self): options = ServerOptions() options.with_external_server(str(server_directory)) options.dot_net_path = sys.executable + options.framework_version = "" options.data_directory = str(Path(directory, "data")) options.logs_path = str(Path(directory, "logs")) options.max_server_startup_time_duration = timedelta(milliseconds=200) @@ -55,6 +56,7 @@ def test_stderr_is_drained_before_and_after_server_is_online(self): options = ServerOptions() options.with_external_server(str(server_directory)) options.dot_net_path = sys.executable + options.framework_version = "" options.data_directory = str(Path(directory, "data")) options.logs_path = str(Path(directory, "logs")) options.max_server_startup_time_duration = timedelta(seconds=10) @@ -80,6 +82,7 @@ def test_failed_server_process_includes_stderr(self): options = ServerOptions() options.with_external_server(str(server_directory)) options.dot_net_path = sys.executable + options.framework_version = "" options.data_directory = str(Path(directory, "data")) options.logs_path = str(Path(directory, "logs")) options.max_server_startup_time_duration = timedelta(seconds=10) From 83138cc732d3f6f9f27bdefb9f149ecb8baafa67 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 29 Jul 2026 14:51:41 +0200 Subject: [PATCH 17/35] RavenDB-27140 Add embedded licensing and explicit EULA acceptance --- README.md | 27 +++++++++++ labs/01_embedded_zero_config.py | 1 + labs/02-embedded-external-server.md | 1 + labs/02_embedded_external_server.py | 1 + labs/03-on-demand-server.md | 1 + labs/03_on_demand_server.py | 1 + labs/04-embedded-secured.md | 1 + labs/04_embedded_secured.py | 1 + labs/05-embedded-persistent.md | 1 + labs/05_embedded_persistent.py | 1 + ravendb_embedded/__init__.py | 2 +- ravendb_embedded/options.py | 13 +++++- ravendb_embedded/raven_server_runner.py | 24 ++++++++++ tests/test_basic.py | 5 +++ tests/test_custom_provider.py | 17 ++++--- tests/test_licensing.py | 59 +++++++++++++++++++++++++ tests/test_lifecycle.py | 1 + tests/test_process_exit.py | 1 + tests/test_secured_basic.py | 3 ++ tests/test_shutdown.py | 2 + tests/test_startup_errors.py | 3 ++ 21 files changed, 157 insertions(+), 9 deletions(-) create mode 100644 tests/test_licensing.py diff --git a/README.md b/README.md index 57e51d4..5f4ee09 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ runtime: from ravendb_embedded import EmbeddedServer, ServerOptions options = ServerOptions() +options.accept_eula = True # Set only after reviewing the RavenDB EULA. options.data_directory = "./data/RavenDB" options.logs_path = "./logs/RavenDB" @@ -69,6 +70,7 @@ self-contained RavenDB build: from ravendb_embedded import EmbeddedServer, ServerOptions options = ServerOptions() +options.accept_eula = True options.with_auto_downloaded_server() options.data_directory = "./data/RavenDB" options.logs_path = "./logs/RavenDB" @@ -117,6 +119,7 @@ Download and extract the Server package for the target platform, then point the from ravendb_embedded import EmbeddedServer, ServerOptions options = ServerOptions() +options.accept_eula = True options.with_external_server("/path/to/extracted/Server") with EmbeddedServer() as server: @@ -136,6 +139,7 @@ Create a `ServerOptions` instance before starting the server: - `data_directory`: where database data is stored. - `logs_path`: where RavenDB writes its logs. +- `accept_eula`: must be set to `True` explicitly after reviewing the RavenDB EULA. - `server_url`: address to bind; the default uses localhost and a free port. - `dot_net_path`: path to `dotnet` for bundled mode when it is not on `PATH`. - `command_line_args`: additional @@ -150,12 +154,35 @@ Create a `ServerOptions` instance before starting the server: Startup failures raise `ServerStartupError`. When the configured startup duration expires, the more specific `ServerStartupTimeoutError` is raised. +### License and EULA + +The package never accepts the RavenDB EULA on your behalf. Review the +[RavenDB EULA](https://ravendb.net/legal/terms) and set `options.accept_eula = True` before +starting RavenDB. + +Configure a license through `options.licensing`: + +```python +options.licensing.license = '{"Id": "..."}' # Inline license JSON +# Or: +options.licensing.license_path = "/path/to/license.json" + +options.licensing.disable_auto_update = True +options.licensing.disable_auto_update_from_api = True +options.licensing.disable_license_support_check = True +options.licensing.throw_on_invalid_or_missing_license = True +``` + +The fail-fast option is useful in CI and production environments where falling back to an +unlicensed server would hide a configuration problem. + ### HTTPS and client certificates Use `ServerOptions.secured()` to start RavenDB over HTTPS: ```python options = ServerOptions() +options.accept_eula = True options.secured( "server.pfx", "client.pem", diff --git a/labs/01_embedded_zero_config.py b/labs/01_embedded_zero_config.py index 3526c97..15611fa 100644 --- a/labs/01_embedded_zero_config.py +++ b/labs/01_embedded_zero_config.py @@ -18,6 +18,7 @@ def main() -> None: with tempfile.TemporaryDirectory() as work_dir: options = ServerOptions() + options.accept_eula = True options.data_directory = str(Path(work_dir, "RavenDB")) options.logs_path = str(Path(work_dir, "Logs")) diff --git a/labs/02-embedded-external-server.md b/labs/02-embedded-external-server.md index 3f8f17f..45b37d3 100644 --- a/labs/02-embedded-external-server.md +++ b/labs/02-embedded-external-server.md @@ -24,6 +24,7 @@ The core is: from ravendb_embedded import EmbeddedServer, ServerOptions options = ServerOptions() +options.accept_eula = True options.with_external_server("/path/to/extracted/Server") # a self-contained build with EmbeddedServer() as server: server.start_server(options) diff --git a/labs/02_embedded_external_server.py b/labs/02_embedded_external_server.py index a004c02..77f3b54 100644 --- a/labs/02_embedded_external_server.py +++ b/labs/02_embedded_external_server.py @@ -26,6 +26,7 @@ def main() -> None: with tempfile.TemporaryDirectory() as work: options = ServerOptions() + options.accept_eula = True options.data_directory = str(Path(work, "data")) options.logs_path = str(Path(work, "logs")) # Bogus on purpose: a self-contained build must never call `dotnet`, so if it still boots the no-.NET path works. diff --git a/labs/03-on-demand-server.md b/labs/03-on-demand-server.md index cc46631..f807b11 100644 --- a/labs/03-on-demand-server.md +++ b/labs/03-on-demand-server.md @@ -22,6 +22,7 @@ The complete example is [`03_on_demand_server.py`](03_on_demand_server.py). The from ravendb_embedded import EmbeddedServer, ServerOptions options = ServerOptions() +options.accept_eula = True options.with_auto_downloaded_server() # download + cache a self-contained server on first use with EmbeddedServer() as server: diff --git a/labs/03_on_demand_server.py b/labs/03_on_demand_server.py index 2bf2079..4b956a8 100644 --- a/labs/03_on_demand_server.py +++ b/labs/03_on_demand_server.py @@ -17,6 +17,7 @@ def main() -> None: with tempfile.TemporaryDirectory() as work: options = ServerOptions() + options.accept_eula = True options.dot_net_path = "__no_dotnet__" options.with_auto_downloaded_server() options.data_directory = str(Path(work, "data")) diff --git a/labs/04-embedded-secured.md b/labs/04-embedded-secured.md index b478edd..181b353 100644 --- a/labs/04-embedded-secured.md +++ b/labs/04-embedded-secured.md @@ -19,6 +19,7 @@ from ravendb_embedded import EmbeddedServer, ServerOptions from ravendb_embedded.options import DatabaseOptions options = ServerOptions() +options.accept_eula = True options.secured(server_pfx_path, client_pem_path, ca_certificate_path=ca_crt_path) with EmbeddedServer() as server: diff --git a/labs/04_embedded_secured.py b/labs/04_embedded_secured.py index 4e64295..c96075d 100644 --- a/labs/04_embedded_secured.py +++ b/labs/04_embedded_secured.py @@ -90,6 +90,7 @@ def main() -> None: server_pfx, client_pem, ca_crt = _demo_certificates(work) options = ServerOptions() + options.accept_eula = True options.secured(server_pfx, client_pem, ca_certificate_path=ca_crt) options.data_directory = str(Path(work, "RavenDB")) options.logs_path = str(Path(work, "Logs")) diff --git a/labs/05-embedded-persistent.md b/labs/05-embedded-persistent.md index 9a1f869..8c58832 100644 --- a/labs/05-embedded-persistent.md +++ b/labs/05-embedded-persistent.md @@ -18,6 +18,7 @@ from ravendb_embedded import EmbeddedServer, ServerOptions def options(data_directory, logs_path): o = ServerOptions() + o.accept_eula = True o.data_directory = data_directory # a fixed folder you reuse across restarts o.logs_path = logs_path return o diff --git a/labs/05_embedded_persistent.py b/labs/05_embedded_persistent.py index 18de0d0..1e97fc9 100644 --- a/labs/05_embedded_persistent.py +++ b/labs/05_embedded_persistent.py @@ -16,6 +16,7 @@ def _options(data_directory, logs_path): options = ServerOptions() + options.accept_eula = True options.data_directory = data_directory options.logs_path = logs_path return options diff --git a/ravendb_embedded/__init__.py b/ravendb_embedded/__init__.py index a72ac5e..963f026 100644 --- a/ravendb_embedded/__init__.py +++ b/ravendb_embedded/__init__.py @@ -4,7 +4,7 @@ ServerStartupError, ServerStartupTimeoutError, ) -from ravendb_embedded.options import DatabaseOptions, ServerOptions, SecurityOptions +from ravendb_embedded.options import DatabaseOptions, LicensingOptions, ServerOptions, SecurityOptions from ravendb_embedded.on_demand import ensure_server from ravendb_embedded.provide import ( CopyServerFromNugetProvider, diff --git a/ravendb_embedded/options.py b/ravendb_embedded/options.py index 4b0975b..c22593e 100644 --- a/ravendb_embedded/options.py +++ b/ravendb_embedded/options.py @@ -38,6 +38,16 @@ def __init__(self): self.certificate_arguments: Optional[str] = None +class LicensingOptions: + def __init__(self): + self.license: Optional[str] = None + self.license_path: Optional[str] = None + self.disable_auto_update: bool = False + self.disable_auto_update_from_api: bool = False + self.disable_license_support_check: bool = True + self.throw_on_invalid_or_missing_license: bool = False + + class ServerOptions: BASE_MODULE_DIRECTORY = str(Path(__file__).parent) DEFAULT_SERVER_LOCATION = os.path.join(BASE_MODULE_DIRECTORY, CopyServerFromNugetProvider.SERVER_FILES) @@ -50,12 +60,13 @@ def __init__(self): self.target_server_location: str = self.DEFAULT_SERVER_LOCATION self.dot_net_path: str = "dotnet" self.clear_target_server_location: bool = False - self.accept_eula: bool = True + self.accept_eula: bool = False self.server_url: Optional[str] = None self.graceful_shutdown_timeout: timedelta = timedelta(seconds=30) self.process_kill_timeout: timedelta = timedelta(seconds=5) self.max_server_startup_time_duration: timedelta = timedelta(minutes=1) self.command_line_args: list[str] = list() + self.licensing: LicensingOptions = LicensingOptions() self.security: Optional[SecurityOptions] = None @classmethod diff --git a/ravendb_embedded/raven_server_runner.py b/ravendb_embedded/raven_server_runner.py index 8c4f759..047f679 100644 --- a/ravendb_embedded/raven_server_runner.py +++ b/ravendb_embedded/raven_server_runner.py @@ -15,6 +15,12 @@ class RavenServerRunner: @staticmethod def run(options: ServerOptions) -> subprocess.Popen: + if not options.accept_eula: + raise ValueError( + "RavenDB EULA acceptance is required. Review the RavenDB EULA and set " + "ServerOptions.accept_eula = True before starting the server." + ) + if not options.target_server_location.strip(): raise ValueError("target_server_location cannot be None or whitespace") @@ -55,11 +61,29 @@ def run(options: ServerOptions) -> subprocess.Popen: command_line_args = [ f"--Embedded.ParentProcessId={RavenServerRunner.get_process_id('0')}", f"--License.Eula.Accepted={'true' if options.accept_eula else 'false'}", + f"--License.DisableAutoUpdate={'true' if options.licensing.disable_auto_update else 'false'}", + ( + "--License.DisableAutoUpdateFromApi=" + f"{'true' if options.licensing.disable_auto_update_from_api else 'false'}" + ), + ( + "--License.DisableLicenseSupportCheck=" + f"{'true' if options.licensing.disable_license_support_check else 'false'}" + ), + ( + "--License.ThrowOnInvalidOrMissingLicense=" + f"{'true' if options.licensing.throw_on_invalid_or_missing_license else 'false'}" + ), "--Setup.Mode=None", f"--DataDir={options.data_directory}", f"--Logs.Path={options.logs_path}", ] + if options.licensing.license is not None: + command_line_args.append(f"--License={options.licensing.license}") + if options.licensing.license_path is not None: + command_line_args.append(f"--License.Path={options.licensing.license_path}") + if options.security: options.server_url = options.server_url or "https://127.0.0.1:0" diff --git a/tests/test_basic.py b/tests/test_basic.py index b432b88..06add01 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -26,6 +26,7 @@ def _initialize_document_store(self, database_name, options): with tempfile.TemporaryDirectory() as temp_dir: embedded = PausingEmbeddedServer() server_options = ServerOptions() + server_options.accept_eula = True server_options.data_directory = str(Path(temp_dir, "RavenDB")) server_options.logs_path = str(Path(temp_dir, "Logs")) server_options.provider = CopyServerFromNugetProvider() @@ -65,6 +66,7 @@ def test_concurrent_calls_share_one_document_store(self): with tempfile.TemporaryDirectory() as temp_dir: with EmbeddedServer() as embedded: server_options = ServerOptions() + server_options.accept_eula = True server_options.data_directory = str(Path(temp_dir, "RavenDB")) server_options.logs_path = str(Path(temp_dir, "Logs")) server_options.provider = CopyServerFromNugetProvider() @@ -80,6 +82,7 @@ def test_close_disposes_open_document_stores(self): with tempfile.TemporaryDirectory() as temp_dir: embedded = EmbeddedServer() server_options = ServerOptions() + server_options.accept_eula = True server_options.data_directory = str(Path(temp_dir, "RavenDB")) server_options.logs_path = str(Path(temp_dir, "Logs")) server_options.provider = CopyServerFromNugetProvider() @@ -100,6 +103,7 @@ def test_embedded(self): try: with EmbeddedServer() as embedded: server_options = ServerOptions() + server_options.accept_eula = True server_options.data_directory = str(Path(temp_dir, "RavenDB")) server_options.logs_path = str(Path(temp_dir, "Logs")) server_options.provider = CopyServerFromNugetProvider() @@ -122,6 +126,7 @@ def test_embedded(self): with EmbeddedServer() as embedded: server_options = ServerOptions() + server_options.accept_eula = True server_options.data_directory = str(Path(temp_dir, "RavenDB")) server_options.provider = CopyServerFromNugetProvider() embedded.start_server(server_options) diff --git a/tests/test_custom_provider.py b/tests/test_custom_provider.py index bae95a7..f3f51ff 100644 --- a/tests/test_custom_provider.py +++ b/tests/test_custom_provider.py @@ -12,6 +12,7 @@ class TestCustomProvider(TestCase): @staticmethod def configure_server_options(temp_dir: str, server_options: ServerOptions) -> ServerOptions: + server_options.accept_eula = True server_options.target_server_location = str(Path(temp_dir, "RavenDBServer")) server_options.data_directory = str(Path(temp_dir, "RavenDB")) server_options.logs_path = str(Path(temp_dir, "Logs")) @@ -55,10 +56,12 @@ def test_can_use_directory_as_external_server_source(self): self.assertIsNone(loaded_person) def test_can_use_default_nuget_provider(self): - with EmbeddedServer() as embedded: - database_options = DatabaseOptions.from_database_name("Test") - embedded.start_server() - with embedded.get_document_store_from_options(database_options) as store: - with store.open_session() as session: - loaded_person = session.load("no-such-person", Person) - self.assertIsNone(loaded_person) + with tempfile.TemporaryDirectory() as temp_directory: + with EmbeddedServer() as embedded: + options = self.configure_server_options(temp_directory, ServerOptions()) + database_options = DatabaseOptions.from_database_name("Test") + embedded.start_server(options) + with embedded.get_document_store_from_options(database_options) as store: + with store.open_session() as session: + loaded_person = session.load("no-such-person", Person) + self.assertIsNone(loaded_person) diff --git a/tests/test_licensing.py b/tests/test_licensing.py new file mode 100644 index 0000000..9a1d053 --- /dev/null +++ b/tests/test_licensing.py @@ -0,0 +1,59 @@ +import json +import sys +import tempfile +from pathlib import Path +from unittest import TestCase + +from ravendb_embedded import EmbeddedServer, ServerOptions +from ravendb_embedded.raven_server_runner import RavenServerRunner + + +class TestLicensing(TestCase): + def test_eula_must_be_accepted_explicitly(self): + options = ServerOptions() + + with self.assertRaises(ValueError) as context: + RavenServerRunner.run(options) + + self.assertIn("set ServerOptions.accept_eula = True", str(context.exception)) + + def test_server_receives_all_licensing_options(self): + with tempfile.TemporaryDirectory() as directory: + server_directory = Path(directory, "Server") + server_directory.mkdir() + arguments_file = Path(directory, "arguments.json") + Path(server_directory, "Raven.Server.dll").write_text( + "import json\n" + "import pathlib\n" + "import sys\n" + f"pathlib.Path({str(arguments_file)!r}).write_text(json.dumps(sys.argv[1:]), encoding='utf-8')\n" + "print('Server available on: http://127.0.0.1:12345', flush=True)\n" + "sys.stdin.readline()\n", + encoding="utf-8", + ) + + options = ServerOptions() + options.accept_eula = True + options.with_external_server(str(server_directory)) + options.dot_net_path = sys.executable + options.framework_version = "" + options.data_directory = str(Path(directory, "data")) + options.logs_path = str(Path(directory, "logs")) + options.licensing.license = '{"Id":"test-license"}' + options.licensing.license_path = str(Path(directory, "license.json")) + options.licensing.disable_auto_update = True + options.licensing.disable_auto_update_from_api = True + options.licensing.disable_license_support_check = False + options.licensing.throw_on_invalid_or_missing_license = True + + with EmbeddedServer() as server: + server.start_server(options) + + arguments = json.loads(arguments_file.read_text(encoding="utf-8")) + self.assertIn("--License.Eula.Accepted=true", arguments) + self.assertIn("--License.DisableAutoUpdate=true", arguments) + self.assertIn("--License.DisableAutoUpdateFromApi=true", arguments) + self.assertIn("--License.DisableLicenseSupportCheck=false", arguments) + self.assertIn("--License.ThrowOnInvalidOrMissingLicense=true", arguments) + self.assertIn('--License={"Id":"test-license"}', arguments) + self.assertIn(f"--License.Path={options.licensing.license_path}", arguments) diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 77cd4e7..b03f971 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -24,6 +24,7 @@ def test_lifecycle_methods_require_a_started_server(self): def test_stop_and_restart_real_server(self): with tempfile.TemporaryDirectory() as directory: options = ServerOptions() + options.accept_eula = True options.data_directory = str(Path(directory, "RavenDB")) options.logs_path = str(Path(directory, "Logs")) options.provider = CopyServerFromNugetProvider() diff --git a/tests/test_process_exit.py b/tests/test_process_exit.py index 51d03b2..bf060d2 100644 --- a/tests/test_process_exit.py +++ b/tests/test_process_exit.py @@ -15,6 +15,7 @@ def server_options(directory, script): Path(server_directory, "Raven.Server.dll").write_text(script, encoding="utf-8") options = ServerOptions() + options.accept_eula = True options.with_external_server(str(server_directory)) options.dot_net_path = sys.executable options.framework_version = "" diff --git a/tests/test_secured_basic.py b/tests/test_secured_basic.py index 820bcc0..2abd52b 100644 --- a/tests/test_secured_basic.py +++ b/tests/test_secured_basic.py @@ -18,6 +18,7 @@ def test_secured_embedded(self): server_pfx, client_pem, ca_crt = generate_self_signed_certificates(temp_dir) with EmbeddedServer() as embedded: server_options = ServerOptions() + server_options.accept_eula = True server_options.secured(server_pfx, client_pem, ca_certificate_path=ca_crt) server_options.data_directory = str(Path(temp_dir, "RavenDB")) server_options.logs_path = str(Path(temp_dir, "Logs")) @@ -41,6 +42,7 @@ def test_secured_embedded_with_separate_admin_certificate(self): server_pfx, client_pem, ca_crt = generate_separate_server_and_client_certificates(temp_dir) with EmbeddedServer() as embedded: server_options = ServerOptions() + server_options.accept_eula = True server_options.secured(server_pfx, client_pem, ca_certificate_path=ca_crt) server_options.data_directory = str(Path(temp_dir, "RavenDB")) server_options.logs_path = str(Path(temp_dir, "Logs")) @@ -67,6 +69,7 @@ def test_secured_embedded_with_certificate_exec(self): with EmbeddedServer() as embedded: server_options = ServerOptions() + server_options.accept_eula = True server_options.secured_with_certificate_exec( sys.executable, f'"{certificate_loader}" "{server_pfx}"', diff --git a/tests/test_shutdown.py b/tests/test_shutdown.py index eb75373..5f5fafb 100644 --- a/tests/test_shutdown.py +++ b/tests/test_shutdown.py @@ -25,6 +25,7 @@ def test_concurrent_start_launches_one_server(self): ) options = ServerOptions() + options.accept_eula = True options.with_external_server(str(server_directory)) options.dot_net_path = sys.executable options.framework_version = "" @@ -65,6 +66,7 @@ def test_close_sends_graceful_shutdown_command(self): ) options = ServerOptions() + options.accept_eula = True options.with_external_server(str(server_directory)) options.dot_net_path = sys.executable options.framework_version = "" diff --git a/tests/test_startup_errors.py b/tests/test_startup_errors.py index b5acf22..04ce5d1 100644 --- a/tests/test_startup_errors.py +++ b/tests/test_startup_errors.py @@ -19,6 +19,7 @@ def test_startup_timeout_has_a_distinct_exception(self): ) options = ServerOptions() + options.accept_eula = True options.with_external_server(str(server_directory)) options.dot_net_path = sys.executable options.framework_version = "" @@ -54,6 +55,7 @@ def test_stderr_is_drained_before_and_after_server_is_online(self): ) options = ServerOptions() + options.accept_eula = True options.with_external_server(str(server_directory)) options.dot_net_path = sys.executable options.framework_version = "" @@ -80,6 +82,7 @@ def test_failed_server_process_includes_stderr(self): ) options = ServerOptions() + options.accept_eula = True options.with_external_server(str(server_directory)) options.dot_net_path = sys.executable options.framework_version = "" From 2721383365c4137f107a9ec5c7eb5455ffc5de0d Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 29 Jul 2026 14:52:35 +0200 Subject: [PATCH 18/35] RavenDB-27140 Support independent embedded server instances --- README.md | 4 +++ ravendb_embedded/embedded_server.py | 1 - tests/test_multiple_servers.py | 42 +++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 tests/test_multiple_servers.py diff --git a/README.md b/README.md index 5f4ee09..83aa9b9 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,10 @@ Use `get_server_process_id()` to inspect the child process. `stop_server()` stop disposing the `EmbeddedServer`, and `restart_server()` starts it again with the same options. Calling `close()` stops the process and disposes all document stores. +`EmbeddedServer` is intentionally not a singleton. An application may run independent server +instances in the same Python process; give each instance a different `data_directory` and +`logs_path`. The context manager owns only its own process and document stores. + Register a process-exit callback when the application needs to observe server failures: ```python diff --git a/ravendb_embedded/embedded_server.py b/ravendb_embedded/embedded_server.py index afd7cdd..3b18a91 100644 --- a/ravendb_embedded/embedded_server.py +++ b/ravendb_embedded/embedded_server.py @@ -37,7 +37,6 @@ class ServerProcessExitedEvent: class EmbeddedServer: - # singleton def __init__(self): self.server_task: Optional[Lazy[Tuple[str, subprocess.Popen]]] = None self._server_options: Optional[ServerOptions] = None diff --git a/tests/test_multiple_servers.py b/tests/test_multiple_servers.py new file mode 100644 index 0000000..1a31b4c --- /dev/null +++ b/tests/test_multiple_servers.py @@ -0,0 +1,42 @@ +import tempfile +from pathlib import Path +from unittest import TestCase + +from ravendb_embedded import EmbeddedServer, ServerOptions + + +class TestMultipleServers(TestCase): + def test_independent_servers_can_run_in_the_same_process(self): + with tempfile.TemporaryDirectory() as directory: + first_options = self._options(directory, "first") + second_options = self._options(directory, "second") + + with EmbeddedServer() as first_server, EmbeddedServer() as second_server: + first_server.start_server(first_options) + second_server.start_server(second_options) + + self.assertNotEqual(first_server.get_server_process_id(), second_server.get_server_process_id()) + self.assertNotEqual(first_server.get_server_uri(), second_server.get_server_uri()) + + with first_server.get_document_store("SharedName") as first_store: + with first_store.open_session() as session: + session.store({"server": "first"}, "markers/1") + session.save_changes() + + with second_server.get_document_store("SharedName") as second_store: + with second_store.open_session() as session: + self.assertIsNone(session.load("markers/1", dict)) + session.store({"server": "second"}, "markers/1") + session.save_changes() + + with first_server.get_document_store("SharedName") as first_store: + with first_store.open_session() as session: + self.assertEqual("first", session.load("markers/1", dict)["server"]) + + @staticmethod + def _options(root: str, name: str) -> ServerOptions: + options = ServerOptions() + options.accept_eula = True + options.data_directory = str(Path(root, name, "data")) + options.logs_path = str(Path(root, name, "logs")) + return options From 37d427e08e741751211e3e15160cd2a45ea90f08 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 29 Jul 2026 14:53:12 +0200 Subject: [PATCH 19/35] RavenDB-27140 Deprecate the misleading ServerOptions instance alias --- README.md | 3 +++ ravendb_embedded/options.py | 6 ++++++ tests/test_options.py | 14 ++++++++++++++ tests/test_runtime_framework_version_matcher.py | 2 +- 4 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 tests/test_options.py diff --git a/README.md b/README.md index 83aa9b9..d6a6a1e 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,9 @@ Runnable walkthrough: [Lab 02 — external self-contained server](labs/02-embedd Create a `ServerOptions` instance before starting the server: +Construct options with `ServerOptions()`. The misleading `ServerOptions.INSTANCE()` constructor +alias is deprecated and will be removed in a future release. + - `data_directory`: where database data is stored. - `logs_path`: where RavenDB writes its logs. - `accept_eula`: must be set to `True` explicitly after reviewing the RavenDB EULA. diff --git a/ravendb_embedded/options.py b/ravendb_embedded/options.py index c22593e..7763f8a 100644 --- a/ravendb_embedded/options.py +++ b/ravendb_embedded/options.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import warnings from datetime import timedelta from pathlib import Path from typing import Optional @@ -71,6 +72,11 @@ def __init__(self): @classmethod def INSTANCE(cls): + warnings.warn( + "ServerOptions.INSTANCE() is deprecated; construct ServerOptions() directly.", + DeprecationWarning, + stacklevel=2, + ) return cls() @classmethod diff --git a/tests/test_options.py b/tests/test_options.py new file mode 100644 index 0000000..2a72279 --- /dev/null +++ b/tests/test_options.py @@ -0,0 +1,14 @@ +from unittest import TestCase + +from ravendb_embedded import ServerOptions + + +class TestServerOptions(TestCase): + def test_instance_is_a_deprecated_constructor_alias(self): + with self.assertWarnsRegex( + DeprecationWarning, + r"construct ServerOptions\(\) directly", + ): + options = ServerOptions.INSTANCE() + + self.assertIsInstance(options, ServerOptions) diff --git a/tests/test_runtime_framework_version_matcher.py b/tests/test_runtime_framework_version_matcher.py index 9cb2674..442a3b9 100644 --- a/tests/test_runtime_framework_version_matcher.py +++ b/tests/test_runtime_framework_version_matcher.py @@ -11,7 +11,7 @@ class TestRuntimeFrameworkVersionMatcher(TestCase): def test_match_1(self): - self.assertEqual("auto", ServerOptions.INSTANCE().framework_version) + self.assertEqual("auto", ServerOptions().framework_version) options = ServerOptions() From 1f309680d6fc479be6c0059f53284baafaae2e89 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 29 Jul 2026 14:53:30 +0200 Subject: [PATCH 20/35] RavenDB-27140 Remove the parent process ID fallback --- ravendb_embedded/raven_server_runner.py | 9 +++------ tests/test_raven_server_runner.py | 9 +++++++++ 2 files changed, 12 insertions(+), 6 deletions(-) create mode 100644 tests/test_raven_server_runner.py diff --git a/ravendb_embedded/raven_server_runner.py b/ravendb_embedded/raven_server_runner.py index 047f679..b01c1c8 100644 --- a/ravendb_embedded/raven_server_runner.py +++ b/ravendb_embedded/raven_server_runner.py @@ -59,7 +59,7 @@ def run(options: ServerOptions) -> subprocess.Popen: # Args are passed to Popen as a list (no shell), so they need no manual escaping. command_line_args = [ - f"--Embedded.ParentProcessId={RavenServerRunner.get_process_id('0')}", + f"--Embedded.ParentProcessId={RavenServerRunner.get_process_id()}", f"--License.Eula.Accepted={'true' if options.accept_eula else 'false'}", f"--License.DisableAutoUpdate={'true' if options.licensing.disable_auto_update else 'false'}", ( @@ -147,8 +147,5 @@ def run(options: ServerOptions) -> subprocess.Popen: return process @staticmethod - def get_process_id(fallback: str) -> str: - try: - return str(os.getpid()) - except Exception: - return fallback + def get_process_id() -> str: + return str(os.getpid()) diff --git a/tests/test_raven_server_runner.py b/tests/test_raven_server_runner.py new file mode 100644 index 0000000..0b84881 --- /dev/null +++ b/tests/test_raven_server_runner.py @@ -0,0 +1,9 @@ +import os +from unittest import TestCase + +from ravendb_embedded.raven_server_runner import RavenServerRunner + + +class TestRavenServerRunner(TestCase): + def test_parent_process_id_is_the_current_python_process(self): + self.assertEqual(str(os.getpid()), RavenServerRunner.get_process_id()) From 626d7b2c5921c3eaaee4ab2c7e73037fa5d7da8d Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 29 Jul 2026 14:54:40 +0200 Subject: [PATCH 21/35] RavenDB-27140 Keep failed embedded server starts retryable --- README.md | 3 ++- tests/test_startup_errors.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d6a6a1e..a2cca2f 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,8 @@ alias is deprecated and will be removed in a future release. - `max_server_startup_time_duration`: maximum server startup time. Startup failures raise `ServerStartupError`. When the configured startup duration expires, the -more specific `ServerStartupTimeoutError` is raised. +more specific `ServerStartupTimeoutError` is raised. A failed start does not dispose the +`EmbeddedServer`; correct the configuration and call `start_server()` on the same instance again. ### License and EULA diff --git a/tests/test_startup_errors.py b/tests/test_startup_errors.py index 04ce5d1..a523da6 100644 --- a/tests/test_startup_errors.py +++ b/tests/test_startup_errors.py @@ -8,6 +8,39 @@ class TestStartupErrors(TestCase): + def test_failed_start_can_be_retried_on_the_same_server(self): + with tempfile.TemporaryDirectory() as directory: + server_directory = Path(directory, "Server") + server_directory.mkdir() + first_attempt = Path(directory, "first-attempt") + Path(server_directory, "Raven.Server.dll").write_text( + "import pathlib\n" + "import sys\n" + f"marker = pathlib.Path({str(first_attempt)!r})\n" + "if not marker.exists():\n" + " marker.touch()\n" + " print('intentional first-start failure', file=sys.stderr, flush=True)\n" + " raise SystemExit(1)\n" + "print('Server available on: http://127.0.0.1:12345', flush=True)\n" + "sys.stdin.readline()\n", + encoding="utf-8", + ) + + options = ServerOptions() + options.accept_eula = True + options.with_external_server(str(server_directory)) + options.dot_net_path = sys.executable + options.framework_version = "" + options.data_directory = str(Path(directory, "data")) + options.logs_path = str(Path(directory, "logs")) + + with EmbeddedServer() as server: + with self.assertRaises(ServerStartupError): + server.start_server(options) + + server.start_server(options) + self.assertEqual("http://127.0.0.1:12345", server.get_server_uri()) + def test_startup_timeout_has_a_distinct_exception(self): with tempfile.TemporaryDirectory() as directory: server_directory = Path(directory, "Server") From 1d9fe7029cbba6c1f0ac58cb105ccb2c7e99f126 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 29 Jul 2026 14:55:13 +0200 Subject: [PATCH 22/35] RavenDB-27140 Harden external server archive extraction --- ravendb_embedded/provide.py | 12 ++++++++++++ tests/test_custom_provider.py | 18 +++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/ravendb_embedded/provide.py b/ravendb_embedded/provide.py index ecffbb3..14c5853 100644 --- a/ravendb_embedded/provide.py +++ b/ravendb_embedded/provide.py @@ -56,7 +56,19 @@ def provide(self, target_directory): @staticmethod def unzip(source: Union[str, bytes], out: str) -> None: + if isinstance(source, bytes): + source = BytesIO(source) + + destination = Path(out).resolve() + + def is_inside_destination(name: str) -> bool: + resolved = (destination / name).resolve() + return resolved == destination or destination in resolved.parents + with zipfile.ZipFile(source, "r") as zipped: + for name in zipped.namelist(): + if not is_inside_destination(name): + raise RuntimeError(f"Refusing to extract unsafe archive path: {name}") zipped.extractall(out) diff --git a/tests/test_custom_provider.py b/tests/test_custom_provider.py index f3f51ff..8a9f67f 100644 --- a/tests/test_custom_provider.py +++ b/tests/test_custom_provider.py @@ -1,15 +1,31 @@ +import io import shutil import tempfile +import zipfile from pathlib import Path from unittest import TestCase from ravendb_embedded.embedded_server import EmbeddedServer from ravendb_embedded.options import ServerOptions, DatabaseOptions -from ravendb_embedded.provide import CopyServerFromNugetProvider +from ravendb_embedded.provide import CopyServerFromNugetProvider, ExtractFromZipServerProvider from tests import Person class TestCustomProvider(TestCase): + def test_external_zip_rejects_path_traversal(self): + archive = io.BytesIO() + with zipfile.ZipFile(archive, "w") as zipped: + zipped.writestr("../escaped.txt", "unsafe") + + with tempfile.TemporaryDirectory() as temp_directory: + destination = Path(temp_directory, "server") + destination.mkdir() + + with self.assertRaisesRegex(RuntimeError, "unsafe archive path"): + ExtractFromZipServerProvider.unzip(archive.getvalue(), str(destination)) + + self.assertFalse(Path(temp_directory, "escaped.txt").exists()) + @staticmethod def configure_server_options(temp_dir: str, server_options: ServerOptions) -> ServerOptions: server_options.accept_eula = True From 6ae7168ccc09a596c4eb2a5a41fae9fab9c2ce01 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 29 Jul 2026 14:56:03 +0200 Subject: [PATCH 23/35] RavenDB-27140 Document and expose reusable server providers --- README.md | 7 +++++++ ravendb_embedded/__init__.py | 2 ++ ravendb_embedded/provide.py | 17 +++++++++++------ tests/test_custom_provider.py | 31 ++++++++++++++++++++++++++++++- 4 files changed, 50 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a2cca2f..a16ba07 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,13 @@ the [RavenDB downloads page](https://ravendb.net/downloads). Runnable walkthrough: [Lab 02 — external self-contained server](labs/02-embedded-external-server.md). +### Advanced server sources + +`with_external_server()` also accepts a ZIP archive containing RavenDB server files. Applications +that ship the archive as a Python package resource can use +`ExtractFromPkgResourceServerProvider(package, resource_name)`. For other sources, implement +`ProvideRavenDBServer.provide(target_directory)` and assign the provider to `options.provider`. + ## Configuration Create a `ServerOptions` instance before starting the server: diff --git a/ravendb_embedded/__init__.py b/ravendb_embedded/__init__.py index 963f026..a4416b4 100644 --- a/ravendb_embedded/__init__.py +++ b/ravendb_embedded/__init__.py @@ -10,5 +10,7 @@ CopyServerFromNugetProvider, CopyServerProvider, ExternalServerProvider, + ExtractFromPkgResourceServerProvider, ExtractFromZipServerProvider, + ProvideRavenDBServer, ) diff --git a/ravendb_embedded/provide.py b/ravendb_embedded/provide.py index 14c5853..3378aea 100644 --- a/ravendb_embedded/provide.py +++ b/ravendb_embedded/provide.py @@ -73,16 +73,21 @@ def is_inside_destination(name: str) -> bool: class ExtractFromPkgResourceServerProvider(ProvideRavenDBServer): - def provide(self, target_directory): - resource_name = "ravendb_server.zip" + def __init__( + self, + package: str = "ravendb_embedded", + resource_name: str = "ravendb_server.zip", + ): + self.package = package + self.resource_name = resource_name - resource_data = pkgutil.get_data(self.__class__.__module__, resource_name) + def provide(self, target_directory): + resource_data = pkgutil.get_data(self.package, self.resource_name) if resource_data is None: - raise RuntimeError(f"Unable to find resource: {resource_name}") + raise RuntimeError(f"Unable to find resource '{self.resource_name}' in package '{self.package}'.") - with BytesIO(resource_data) as bytes_buffer: - ExtractFromZipServerProvider.unzip(bytes_buffer.read(), target_directory) + ExtractFromZipServerProvider.unzip(resource_data, target_directory) class ExternalServerProvider(ProvideRavenDBServer): diff --git a/tests/test_custom_provider.py b/tests/test_custom_provider.py index 8a9f67f..05f2726 100644 --- a/tests/test_custom_provider.py +++ b/tests/test_custom_provider.py @@ -1,5 +1,7 @@ import io +import importlib import shutil +import sys import tempfile import zipfile from pathlib import Path @@ -7,11 +9,38 @@ from ravendb_embedded.embedded_server import EmbeddedServer from ravendb_embedded.options import ServerOptions, DatabaseOptions -from ravendb_embedded.provide import CopyServerFromNugetProvider, ExtractFromZipServerProvider +from ravendb_embedded.provide import ( + CopyServerFromNugetProvider, + ExtractFromPkgResourceServerProvider, + ExtractFromZipServerProvider, +) from tests import Person class TestCustomProvider(TestCase): + def test_can_extract_server_from_a_package_resource(self): + with tempfile.TemporaryDirectory() as temp_directory: + package_directory = Path(temp_directory, "example_server_package") + package_directory.mkdir() + Path(package_directory, "__init__.py").touch() + resource = Path(package_directory, "server.zip") + with zipfile.ZipFile(resource, "w") as zipped: + zipped.writestr("Server/Raven.Server.dll", "server") + + sys.path.insert(0, temp_directory) + importlib.invalidate_caches() + try: + destination = Path(temp_directory, "extracted") + ExtractFromPkgResourceServerProvider( + "example_server_package", + "server.zip", + ).provide(str(destination)) + self.assertTrue(Path(destination, "Server", "Raven.Server.dll").is_file()) + finally: + sys.modules.pop("example_server_package", None) + sys.path.remove(temp_directory) + importlib.invalidate_caches() + def test_external_zip_rejects_path_traversal(self): archive = io.BytesIO() with zipfile.ZipFile(archive, "w") as zipped: From 48e617d793a5b9f4e07e56d7519d473cbdd71898 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 29 Jul 2026 14:56:20 +0200 Subject: [PATCH 24/35] RavenDB-27140 Document the synchronous embedded execution model --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a16ba07..5ad30a0 100644 --- a/README.md +++ b/README.md @@ -225,9 +225,15 @@ Use `get_server_process_id()` to inspect the child process. `stop_server()` stop disposing the `EmbeddedServer`, and `restart_server()` starts it again with the same options. Calling `close()` stops the process and disposes all document stores. +The public API is synchronous, matching the RavenDB Python client's execution model. Server start, +shutdown, and restart calls block until the requested lifecycle transition completes. Async +applications can run these blocking lifecycle calls with `asyncio.to_thread()` instead of +maintaining a second embedded API with different behavior. + `EmbeddedServer` is intentionally not a singleton. An application may run independent server instances in the same Python process; give each instance a different `data_directory` and -`logs_path`. The context manager owns only its own process and document stores. +`logs_path`. Prefer `with EmbeddedServer() as server:` so the context manager always closes that +instance's process and document stores. Register a process-exit callback when the application needs to observe server failures: From a0acef48da667ca426ad597ed2629463bab128f6 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 29 Jul 2026 15:00:27 +0200 Subject: [PATCH 25/35] RavenDB-27140 Test the bundled server in its installed location --- tests/test_custom_provider.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_custom_provider.py b/tests/test_custom_provider.py index 05f2726..169ccd6 100644 --- a/tests/test_custom_provider.py +++ b/tests/test_custom_provider.py @@ -103,7 +103,10 @@ def test_can_use_directory_as_external_server_source(self): def test_can_use_default_nuget_provider(self): with tempfile.TemporaryDirectory() as temp_directory: with EmbeddedServer() as embedded: - options = self.configure_server_options(temp_directory, ServerOptions()) + options = ServerOptions() + options.accept_eula = True + options.data_directory = str(Path(temp_directory, "RavenDB")) + options.logs_path = str(Path(temp_directory, "Logs")) database_options = DatabaseOptions.from_database_name("Test") embedded.start_server(options) with embedded.get_document_store_from_options(database_options) as store: From 3c3dc4011d9cacf07a897babfa525c3bfcc1e296 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 29 Jul 2026 15:06:16 +0200 Subject: [PATCH 26/35] RavenDB-27140 Move default server data out of site-packages --- README.md | 17 +++++++++++------ ravendb_embedded/options.py | 20 ++++++++++++++++++-- tests/test_basic.py | 21 +++++++++++++++++++++ tests/test_options.py | 24 ++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5ad30a0..4d71b61 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,6 @@ from ravendb_embedded import EmbeddedServer, ServerOptions options = ServerOptions() options.accept_eula = True # Set only after reviewing the RavenDB EULA. -options.data_directory = "./data/RavenDB" -options.logs_path = "./logs/RavenDB" with EmbeddedServer() as server: server.start_server(options) @@ -147,8 +145,10 @@ Create a `ServerOptions` instance before starting the server: Construct options with `ServerOptions()`. The misleading `ServerOptions.INSTANCE()` constructor alias is deprecated and will be removed in a future release. -- `data_directory`: where database data is stored. -- `logs_path`: where RavenDB writes its logs. +- `data_directory`: where database data is stored; defaults to `./RavenDB` in the application's + current working directory. +- `logs_path`: where RavenDB writes its logs; defaults to `/Logs` and follows a + changed `data_directory` until you set `logs_path` explicitly. - `accept_eula`: must be set to `True` explicitly after reviewing the RavenDB EULA. - `server_url`: address to bind; the default uses localhost and a free port. - `dot_net_path`: path to `dotnet` for bundled mode when it is not on `PATH`. @@ -248,8 +248,13 @@ Callbacks run on a background thread. `event.expected` is `True` for exits cause ### Persistent data -Set `data_directory` to a stable path when data should survive process restarts. Use a temporary -directory for disposable tests. +The default `./RavenDB` directory survives process restarts. Set `data_directory` explicitly when +the application can run from different working directories or when several embedded servers run +in one process. Use a temporary directory for disposable tests. + +Older package versions defaulted to a directory inside the installed Python package. Applications +that need to reuse data created there should point `data_directory` at that existing location +explicitly before upgrading their storage layout. Runnable walkthrough: [Lab 05 — persistent data](labs/05-embedded-persistent.md). diff --git a/ravendb_embedded/options.py b/ravendb_embedded/options.py index 7763f8a..ee0c10c 100644 --- a/ravendb_embedded/options.py +++ b/ravendb_embedded/options.py @@ -55,8 +55,8 @@ class ServerOptions: def __init__(self): self.framework_version: Optional[str] = "auto" - self.logs_path: str = self.BASE_MODULE_DIRECTORY + "/RavenDB/Logs" - self.data_directory: str = self.BASE_MODULE_DIRECTORY + "/RavenDB" + self._data_directory: str = str(Path.cwd() / "RavenDB") + self._logs_path: Optional[str] = None self.provider: ProvideRavenDBServer = CopyServerFromNugetProvider() self.target_server_location: str = self.DEFAULT_SERVER_LOCATION self.dot_net_path: str = "dotnet" @@ -70,6 +70,22 @@ def __init__(self): self.licensing: LicensingOptions = LicensingOptions() self.security: Optional[SecurityOptions] = None + @property + def data_directory(self) -> str: + return self._data_directory + + @data_directory.setter + def data_directory(self, value: str) -> None: + self._data_directory = value + + @property + def logs_path(self) -> str: + return self._logs_path or str(Path(self._data_directory) / "Logs") + + @logs_path.setter + def logs_path(self, value: Optional[str]) -> None: + self._logs_path = value + @classmethod def INSTANCE(cls): warnings.warn( diff --git a/tests/test_basic.py b/tests/test_basic.py index 06add01..ffe2d01 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,3 +1,4 @@ +import os import shutil import tempfile from concurrent.futures import ThreadPoolExecutor @@ -10,6 +11,26 @@ class BasicTest(TestCase): + def test_default_data_and_log_locations_are_runnable(self): + previous_directory = Path.cwd() + with tempfile.TemporaryDirectory() as temp_dir: + try: + os.chdir(temp_dir) + options = ServerOptions() + options.accept_eula = True + + with EmbeddedServer() as embedded: + embedded.start_server(options) + with embedded.get_document_store("Defaults") as store: + with store.open_session() as session: + session.store({"works": True}, "defaults/1") + session.save_changes() + + self.assertTrue(Path(temp_dir, "RavenDB").is_dir()) + self.assertTrue(Path(temp_dir, "RavenDB", "Logs").is_dir()) + finally: + os.chdir(previous_directory) + def test_close_waits_for_document_store_initialization(self): initialization_started = Event() continue_initialization = Event() diff --git a/tests/test_options.py b/tests/test_options.py index 2a72279..040a2d7 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -1,9 +1,33 @@ +import os +import tempfile +from pathlib import Path from unittest import TestCase from ravendb_embedded import ServerOptions class TestServerOptions(TestCase): + def test_default_data_and_logs_are_under_the_working_directory(self): + previous_directory = Path.cwd() + with tempfile.TemporaryDirectory() as directory: + try: + os.chdir(directory) + options = ServerOptions() + + self.assertEqual(str(Path(directory, "RavenDB")), options.data_directory) + self.assertEqual(str(Path(directory, "RavenDB", "Logs")), options.logs_path) + finally: + os.chdir(previous_directory) + + def test_default_logs_follow_an_explicit_data_directory(self): + options = ServerOptions() + options.data_directory = "custom-data" + self.assertEqual(str(Path("custom-data", "Logs")), options.logs_path) + + options.logs_path = "custom-logs" + options.data_directory = "other-data" + self.assertEqual("custom-logs", options.logs_path) + def test_instance_is_a_deprecated_constructor_alias(self): with self.assertWarnsRegex( DeprecationWarning, From 805cd8050f0a35aaf789cd4869caa3d3eec973ed Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 29 Jul 2026 15:08:40 +0200 Subject: [PATCH 27/35] RavenDB-27140 Validate secured embedded client credentials early --- README.md | 8 ++ ravendb_embedded/options.py | 5 ++ ravendb_embedded/raven_server_runner.py | 96 ++++++++++++++++++++-- tests/test_security_validation.py | 105 ++++++++++++++++++++++++ 4 files changed, 206 insertions(+), 8 deletions(-) create mode 100644 tests/test_security_validation.py diff --git a/README.md b/README.md index 4d71b61..78fe30e 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,14 @@ options.secured( ``` The returned `DocumentStore` receives the client certificate and custom CA automatically. +The client PEM must contain both its certificate and matching unencrypted private key. If the +certificate declares Extended Key Usage, it must include TLS Client Authentication. + +The server PFX is deliberately not reused as a client certificate: a server-only certificate may +not have Client Authentication EKU, and its PFX may not contain a suitable client identity. Supply +the client PEM explicitly. `ca_certificate_path` is optional for certificates trusted by the +operating system; provide a CA bundle for private or self-signed server certificates. These files +are validated before the RavenDB process starts. When another process supplies the server certificate, use `secured_with_certificate_exec()`: diff --git a/ravendb_embedded/options.py b/ravendb_embedded/options.py index ee0c10c..493a868 100644 --- a/ravendb_embedded/options.py +++ b/ravendb_embedded/options.py @@ -110,6 +110,11 @@ def secured( ) -> "ServerOptions": if server_pfx_certificate_path is None: raise ValueError("certificate cannot be None") + if not client_pem_certificate_path: + raise ValueError( + "client_pem_certificate_path is required. A server PFX is not reused automatically " + "because it may not contain a client-authentication certificate and private key." + ) if self.security is not None: raise RuntimeError("The security has already been set up for this ServerOptions object") diff --git a/ravendb_embedded/raven_server_runner.py b/ravendb_embedded/raven_server_runner.py index b01c1c8..8f2ac4e 100644 --- a/ravendb_embedded/raven_server_runner.py +++ b/ravendb_embedded/raven_server_runner.py @@ -1,9 +1,11 @@ import os +import re import subprocess from cryptography import x509 from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.x509.oid import ExtendedKeyUsageOID, ExtensionOID from ravendb.exceptions.raven_exceptions import RavenException from ravendb_embedded.options import ServerOptions @@ -13,6 +15,16 @@ class RavenServerRunner: + _CERTIFICATE_PATTERN = re.compile( + br"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", + re.DOTALL, + ) + _PRIVATE_KEY_PATTERN = re.compile( + br"-----BEGIN (?:RSA |EC |DSA |ENCRYPTED )?PRIVATE KEY-----.*?" + br"-----END (?:RSA |EC |DSA |ENCRYPTED )?PRIVATE KEY-----", + re.DOTALL, + ) + @staticmethod def run(options: ServerOptions) -> subprocess.Popen: if not options.accept_eula: @@ -21,6 +33,8 @@ def run(options: ServerOptions) -> subprocess.Popen: "ServerOptions.accept_eula = True before starting the server." ) + client_certificate = RavenServerRunner._validate_security_options(options) + if not options.target_server_location.strip(): raise ValueError("target_server_location cannot be None or whitespace") @@ -101,13 +115,8 @@ def run(options: ServerOptions) -> subprocess.Popen: f"--Security.Certificate.Load.Exec.Arguments={options.security.certificate_arguments}", ] ) - if options.security.client_pem_certificate_path: - with open(options.security.client_pem_certificate_path, "rb") as cert_file: - cert_data = cert_file.read() - - cert = x509.load_pem_x509_certificate(cert_data, default_backend()) - thumbprint = cert.fingerprint(hashes.SHA1()).hex().upper() - + if client_certificate: + thumbprint = client_certificate.fingerprint(hashes.SHA1()).hex().upper() command_line_args.append(f"--Security.WellKnownCertificates.Admin={thumbprint}") else: options.server_url = options.server_url or "http://127.0.0.1:0" @@ -146,6 +155,77 @@ def run(options: ServerOptions) -> subprocess.Popen: return process + @staticmethod + def _validate_security_options(options: ServerOptions) -> x509.Certificate | None: + security = options.security + if security is None: + return None + if not security.client_pem_certificate_path: + raise ValueError( + "A secured embedded server requires client_pem_certificate_path. " + "The server PFX is not automatically reused as a client certificate." + ) + + client_path = security.client_pem_certificate_path + try: + with open(client_path, "rb") as client_file: + client_data = client_file.read() + except OSError as error: + raise ValueError(f"Unable to read the client PEM certificate '{client_path}': {error}") from error + + certificate_match = RavenServerRunner._CERTIFICATE_PATTERN.search(client_data) + if certificate_match is None: + raise ValueError(f"Client PEM '{client_path}' does not contain an X.509 certificate.") + try: + certificate = x509.load_pem_x509_certificate(certificate_match.group(), default_backend()) + except ValueError as error: + raise ValueError(f"Client PEM '{client_path}' contains an invalid X.509 certificate.") from error + + key_match = RavenServerRunner._PRIVATE_KEY_PATTERN.search(client_data) + if key_match is None: + raise ValueError(f"Client PEM '{client_path}' does not contain a private key.") + try: + private_key = serialization.load_pem_private_key(key_match.group(), password=None) + except (TypeError, ValueError) as error: + raise ValueError( + f"Client PEM '{client_path}' must contain a valid, unencrypted private key." + ) from error + + public_format = serialization.PublicFormat.SubjectPublicKeyInfo + certificate_key = certificate.public_key().public_bytes(serialization.Encoding.DER, public_format) + private_key_public = private_key.public_key().public_bytes(serialization.Encoding.DER, public_format) + if certificate_key != private_key_public: + raise ValueError(f"The certificate and private key in client PEM '{client_path}' do not match.") + + try: + extended_key_usage = certificate.extensions.get_extension_for_oid(ExtensionOID.EXTENDED_KEY_USAGE).value + except x509.ExtensionNotFound: + pass + else: + if ExtendedKeyUsageOID.CLIENT_AUTH not in extended_key_usage: + raise ValueError( + f"Client certificate '{client_path}' does not allow TLS client authentication." + ) + + if security.ca_certificate_path: + ca_path = security.ca_certificate_path + try: + with open(ca_path, "rb") as ca_file: + ca_data = ca_file.read() + except OSError as error: + raise ValueError(f"Unable to read the CA certificate bundle '{ca_path}': {error}") from error + + ca_certificates = RavenServerRunner._CERTIFICATE_PATTERN.findall(ca_data) + if not ca_certificates: + raise ValueError(f"CA certificate bundle '{ca_path}' does not contain an X.509 certificate.") + try: + for ca_certificate in ca_certificates: + x509.load_pem_x509_certificate(ca_certificate, default_backend()) + except ValueError as error: + raise ValueError(f"CA certificate bundle '{ca_path}' contains an invalid certificate.") from error + + return certificate + @staticmethod def get_process_id() -> str: return str(os.getpid()) diff --git a/tests/test_security_validation.py b/tests/test_security_validation.py new file mode 100644 index 0000000..fec9738 --- /dev/null +++ b/tests/test_security_validation.py @@ -0,0 +1,105 @@ +import datetime +import tempfile +from pathlib import Path +from unittest import TestCase + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + +from ravendb_embedded import ServerOptions +from ravendb_embedded.raven_server_runner import RavenServerRunner + + +class TestSecurityValidation(TestCase): + def test_secured_requires_an_explicit_client_certificate(self): + with self.assertRaisesRegex(ValueError, "client_pem_certificate_path is required"): + ServerOptions().secured("server.pfx") + + def test_client_pem_must_contain_a_private_key(self): + with tempfile.TemporaryDirectory() as directory: + _, certificate = self._create_certificate([ExtendedKeyUsageOID.CLIENT_AUTH]) + client_pem = Path(directory, "client.pem") + client_pem.write_bytes(certificate.public_bytes(serialization.Encoding.PEM)) + + options = self._secured_options(client_pem) + with self.assertRaisesRegex(ValueError, "does not contain a private key"): + RavenServerRunner.run(options) + + def test_client_certificate_and_private_key_must_match(self): + with tempfile.TemporaryDirectory() as directory: + first_key, _ = self._create_certificate([ExtendedKeyUsageOID.CLIENT_AUTH]) + _, second_certificate = self._create_certificate([ExtendedKeyUsageOID.CLIENT_AUTH]) + client_pem = Path(directory, "client.pem") + client_pem.write_bytes( + self._private_key_pem(first_key) + + second_certificate.public_bytes(serialization.Encoding.PEM) + ) + + options = self._secured_options(client_pem) + with self.assertRaisesRegex(ValueError, "do not match"): + RavenServerRunner.run(options) + + def test_declared_eku_must_allow_client_authentication(self): + with tempfile.TemporaryDirectory() as directory: + key, certificate = self._create_certificate([ExtendedKeyUsageOID.SERVER_AUTH]) + client_pem = Path(directory, "client.pem") + client_pem.write_bytes( + self._private_key_pem(key) + certificate.public_bytes(serialization.Encoding.PEM) + ) + + options = self._secured_options(client_pem) + with self.assertRaisesRegex(ValueError, "does not allow TLS client authentication"): + RavenServerRunner.run(options) + + def test_ca_path_must_contain_a_certificate(self): + with tempfile.TemporaryDirectory() as directory: + key, certificate = self._create_certificate([ExtendedKeyUsageOID.CLIENT_AUTH]) + client_pem = Path(directory, "client.pem") + client_pem.write_bytes( + self._private_key_pem(key) + certificate.public_bytes(serialization.Encoding.PEM) + ) + ca_path = Path(directory, "ca.crt") + ca_path.write_text("not a certificate", encoding="utf-8") + + options = self._secured_options(client_pem, ca_path) + with self.assertRaisesRegex(ValueError, "does not contain an X.509 certificate"): + RavenServerRunner.run(options) + + @staticmethod + def _secured_options(client_pem: Path, ca_path: Path = None) -> ServerOptions: + options = ServerOptions() + options.accept_eula = True + options.secured( + "server.pfx", + str(client_pem), + ca_certificate_path=str(ca_path) if ca_path else None, + ) + return options + + @staticmethod + def _private_key_pem(key) -> bytes: + return key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + ) + + @staticmethod + def _create_certificate(extended_key_usage): + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "embedded-client")]) + now = datetime.datetime.now(datetime.timezone.utc) + certificate = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=1)) + .add_extension(x509.ExtendedKeyUsage(extended_key_usage), critical=False) + .sign(key, hashes.SHA256()) + ) + return key, certificate From 220fcfec24d572ecc0f9e0c2e2862ad7222aa1bf Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 29 Jul 2026 15:13:12 +0200 Subject: [PATCH 28/35] RavenDB-27140 Fix the RavenDB EULA documentation link --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 78fe30e..f6d2194 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,8 @@ more specific `ServerStartupTimeoutError` is raised. A failed start does not dis ### License and EULA The package never accepts the RavenDB EULA on your behalf. Review the -[RavenDB EULA](https://ravendb.net/legal/terms) and set `options.accept_eula = True` before +[RavenDB EULA](https://ravendb.net/legal/ravendb/commercial-license-eula) and set +`options.accept_eula = True` before starting RavenDB. Configure a license through `options.licensing`: From 1467aee15248d319ef258e1a72f50eb5c5d77468 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 29 Jul 2026 19:21:25 +0200 Subject: [PATCH 29/35] RavenDB-27140 Clarify customer-facing server behavior --- README.md | 2 +- tests/test_on_demand.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f6d2194..be10cbd 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ The returned `DocumentStore` receives the client certificate and custom CA autom The client PEM must contain both its certificate and matching unencrypted private key. If the certificate declares Extended Key Usage, it must include TLS Client Authentication. -The server PFX is deliberately not reused as a client certificate: a server-only certificate may +The server PFX is not reused as a client certificate: a server-only certificate may not have Client Authentication EKU, and its PFX may not contain a suitable client identity. Supply the client PEM explicitly. `ca_certificate_path` is optional for certificates trusted by the operating system; provide a CA bundle for private or self-signed server certificates. These files diff --git a/tests/test_on_demand.py b/tests/test_on_demand.py index 226f691..5de0157 100644 --- a/tests/test_on_demand.py +++ b/tests/test_on_demand.py @@ -82,7 +82,7 @@ def _download_from(base_url): class TestOnDemand(unittest.TestCase): def test_cache_hit_skips_download(self): - # A populated cache must be reused without any network call ("by design"). + # A populated cache must be reused without any network call. with tempfile.TemporaryDirectory() as cache_root: label, _ = _platform_download() server_dir = Path(cache_root, "7.2", label.replace(" ", "_"), "Server") From 985eb5fa700c4dc24f0c45ef6c0dd47425a797d9 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 29 Jul 2026 19:47:12 +0200 Subject: [PATCH 30/35] RavenDB-27140 Add lifecycle and multi-instance examples --- README.md | 35 ++++++++++++++++++++ labs/01-embedded-zero-config.md | 15 +++++---- labs/05-embedded-persistent.md | 4 +-- labs/06-embedded-lifecycle.md | 57 +++++++++++++++++++++++++++++++++ labs/06_embedded_lifecycle.py | 54 +++++++++++++++++++++++++++++++ labs/README.md | 1 + 6 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 labs/06-embedded-lifecycle.md create mode 100644 labs/06_embedded_lifecycle.py diff --git a/README.md b/README.md index be10cbd..371e475 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,23 @@ Startup failures raise `ServerStartupError`. When the configured startup duratio more specific `ServerStartupTimeoutError` is raised. A failed start does not dispose the `EmbeddedServer`; correct the configuration and call `start_server()` on the same instance again. +```python +from datetime import timedelta + +from ravendb_embedded import EmbeddedServer, ServerOptions, ServerStartupTimeoutError + +options = ServerOptions() +options.accept_eula = True +options.max_server_startup_time_duration = timedelta(seconds=15) + +with EmbeddedServer() as server: + try: + server.start_server(options) + except ServerStartupTimeoutError: + options.max_server_startup_time_duration = timedelta(minutes=1) + server.start_server(options) +``` + ### License and EULA The package never accepts the RavenDB EULA on your behalf. Review the @@ -244,6 +261,21 @@ instances in the same Python process; give each instance a different `data_direc `logs_path`. Prefer `with EmbeddedServer() as server:` so the context manager always closes that instance's process and document stores. +```python +first_options = ServerOptions() +first_options.accept_eula = True +first_options.data_directory = "./servers/first" + +second_options = ServerOptions() +second_options.accept_eula = True +second_options.data_directory = "./servers/second" + +with EmbeddedServer() as first, EmbeddedServer() as second: + first.start_server(first_options) + second.start_server(second_options) + assert first.get_server_process_id() != second.get_server_process_id() +``` + Register a process-exit callback when the application needs to observe server failures: ```python @@ -255,6 +287,8 @@ server.add_server_process_exited( Callbacks run on a background thread. `event.expected` is `True` for exits caused by `stop_server()`, `restart_server()`, or `close()`, and `False` when the child exits unexpectedly. +Runnable walkthrough: [Lab 06 — lifecycle and process monitoring](labs/06-embedded-lifecycle.md). + ### Persistent data The default `./RavenDB` directory survives process restarts. Set `data_directory` explicitly when @@ -287,6 +321,7 @@ The repository contains runnable, self-checking examples: | [03](labs/03-on-demand-server.md) | Automatic platform detection, download, and cache | No | | [04](labs/04-embedded-secured.md) | HTTPS and client-certificate authentication | Yes | | [05](labs/05-embedded-persistent.md) | Data that survives server restarts | Yes | +| [06](labs/06-embedded-lifecycle.md) | Stop, restart, PID, and process-exit monitoring | Yes | The scripts live in the repository rather than the installed wheel. Clone or download the repository, install the package, and run them from the repository root. See the diff --git a/labs/01-embedded-zero-config.md b/labs/01-embedded-zero-config.md index 852a317..fbbf034 100644 --- a/labs/01-embedded-zero-config.md +++ b/labs/01-embedded-zero-config.md @@ -17,10 +17,13 @@ The complete, runnable example is [`01_embedded_zero_config.py`](01_embedded_zer The core is just: ```python -from ravendb_embedded import EmbeddedServer +from ravendb_embedded import EmbeddedServer, ServerOptions + +options = ServerOptions() +options.accept_eula = True with EmbeddedServer() as server: - server.start_server() + server.start_server(options) with server.get_document_store("Lab") as store: with store.open_session() as session: session.store({"name": "Ayende"}, "people/1") @@ -33,10 +36,10 @@ with EmbeddedServer() as server: server (`Raven.Server.dll`) that ships inside the wheel. 2. `RavenServerRunner` launches it as **`dotnet Raven.Server.dll ...`**, using the `dotnet` found on your `PATH` (`ServerOptions.dot_net_path`). -3. `ServerOptions.framework_version` is empty by default, so no `--fx-version` is passed and - standard .NET host resolution applies. The host does **not** roll forward across major - versions, which is why a net10.0 server hard-fails on a machine that only has .NET 8 - (error: "You must install ... version 10.0.x"). +3. `ServerOptions.framework_version` defaults to `auto`. The package reads the bundled + `Raven.Server.runtimeconfig.json`, finds the required .NET line, and selects a compatible + installed patch. The host does **not** roll forward across major versions, which is why a + net10.0 server still requires .NET 10. 4. The one exception: point the server at a **self-contained** build (an apphost `Raven.Server` with the runtime bundled) and it runs **without `dotnet`**. That is Lab 02. diff --git a/labs/05-embedded-persistent.md b/labs/05-embedded-persistent.md index 8c58832..a1d7063 100644 --- a/labs/05-embedded-persistent.md +++ b/labs/05-embedded-persistent.md @@ -38,8 +38,8 @@ with EmbeddedServer() as server: ## Notes -- The default `data_directory` is a folder under the package location, which is fine for tests - but not what you want for real data. Set it explicitly to a path you control. +- The default `data_directory` is `./RavenDB` under the current working directory. Set it + explicitly when the application can start from different directories or needs a fixed location. - `get_document_store("Lab")` reuses the existing database on the second run; it only creates one when it is missing. diff --git a/labs/06-embedded-lifecycle.md b/labs/06-embedded-lifecycle.md new file mode 100644 index 0000000..ed3e999 --- /dev/null +++ b/labs/06-embedded-lifecycle.md @@ -0,0 +1,57 @@ +# Lab 06: Server lifecycle and process monitoring + +**For:** applications that need to stop or restart their managed RavenDB process and observe +whether a process exit was expected or unexpected. + +## Run it + +```bash +pip install ravendb-embedded +python labs/06_embedded_lifecycle.py +``` + +The complete, self-checking example is +[`06_embedded_lifecycle.py`](06_embedded_lifecycle.py). It exercises: + +- `get_server_process_id()` +- `stop_server()` +- `restart_server()` +- `add_server_process_exited()` +- document access after restart + +The core lifecycle flow is: + +```python +from threading import Event + +from ravendb_embedded import EmbeddedServer, ServerOptions + +options = ServerOptions() +options.accept_eula = True + +exit_observed = Event() +exit_events = [] + +with EmbeddedServer() as server: + server.add_server_process_exited( + lambda event: (exit_events.append(event), exit_observed.set()) + ) + server.start_server(options) + first_process_id = server.get_server_process_id() + + server.stop_server() + assert exit_observed.wait(10) + assert exit_events[-1].process_id == first_process_id + assert exit_events[-1].expected + + server.restart_server() + assert server.get_server_process_id() != first_process_id +``` + +`event.expected` is `True` for `stop_server()`, `restart_server()`, and context-manager cleanup. +It is `False` when RavenDB exits on its own. + +## Takeaway + +`EmbeddedServer` owns the RavenDB child process but exposes enough lifecycle information for an +application to coordinate restarts and distinguish planned shutdowns from server failures. diff --git a/labs/06_embedded_lifecycle.py b/labs/06_embedded_lifecycle.py new file mode 100644 index 0000000..c07ff56 --- /dev/null +++ b/labs/06_embedded_lifecycle.py @@ -0,0 +1,54 @@ +"""Lab 06: Stop, restart, process IDs, and process-exit notifications.""" + +import tempfile +from pathlib import Path +from threading import Event + +from ravendb_embedded import EmbeddedServer, ServerOptions + + +def main() -> None: + with tempfile.TemporaryDirectory() as work: + options = ServerOptions() + options.accept_eula = True + options.data_directory = str(Path(work, "data")) + options.logs_path = str(Path(work, "logs")) + + exit_observed = Event() + exit_events = [] + + def on_server_exit(event) -> None: + exit_events.append(event) + exit_observed.set() + + with EmbeddedServer() as server: + server.add_server_process_exited(on_server_exit) + server.start_server(options) + first_process_id = server.get_server_process_id() + print(f"Started RavenDB process {first_process_id}") + + server.stop_server() + if not exit_observed.wait(10): + raise RuntimeError("The server exit callback was not invoked.") + + stopped = exit_events[-1] + assert stopped.process_id == first_process_id + assert stopped.expected + print(f"Stopped RavenDB process {stopped.process_id}") + + exit_observed.clear() + server.restart_server() + second_process_id = server.get_server_process_id() + assert second_process_id != first_process_id + print(f"Restarted RavenDB as process {second_process_id}") + + with server.get_document_store("LifecycleLab") as store: + with store.open_session() as session: + session.store({"status": "running"}, "status/1") + session.save_changes() + + print("PASS: stop, exit notification, restart, and document access succeeded.") + + +if __name__ == "__main__": + main() diff --git a/labs/README.md b/labs/README.md index 1331b90..de61f60 100644 --- a/labs/README.md +++ b/labs/README.md @@ -14,6 +14,7 @@ the released library and server binaries used by the scripts. | [03](03-on-demand-server.md) | On-demand cached self-contained download (no manual steps) | No | | [04](04-embedded-secured.md) | Secured embedded server (HTTPS + client certificate) | Yes | | [05](05-embedded-persistent.md) | Persistent data directory (data survives restarts) | Yes | +| [06](06-embedded-lifecycle.md) | Stop, restart, PID, and process-exit monitoring | Yes | RavenDB version to .NET mapping: **7.1.x needs .NET 8, 7.2.x needs .NET 10.** The bundled server decides this, so it can change on a minor bump; check the lab for your version. From d01cc6509ddb327f8de1225e7f1cfbbfde0049e2 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 29 Jul 2026 19:50:35 +0200 Subject: [PATCH 31/35] RavenDB-27140 Format Python sources --- ravendb_embedded/raven_server_runner.py | 14 +++++--------- tests/certificates.py | 4 +--- tests/test_process_exit.py | 5 ++--- tests/test_secured_basic.py | 4 +--- tests/test_security_validation.py | 11 +++-------- tests/test_startup_errors.py | 3 +-- 6 files changed, 13 insertions(+), 28 deletions(-) diff --git a/ravendb_embedded/raven_server_runner.py b/ravendb_embedded/raven_server_runner.py index 8f2ac4e..56cf961 100644 --- a/ravendb_embedded/raven_server_runner.py +++ b/ravendb_embedded/raven_server_runner.py @@ -16,12 +16,12 @@ class RavenServerRunner: _CERTIFICATE_PATTERN = re.compile( - br"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", + rb"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", re.DOTALL, ) _PRIVATE_KEY_PATTERN = re.compile( - br"-----BEGIN (?:RSA |EC |DSA |ENCRYPTED )?PRIVATE KEY-----.*?" - br"-----END (?:RSA |EC |DSA |ENCRYPTED )?PRIVATE KEY-----", + rb"-----BEGIN (?:RSA |EC |DSA |ENCRYPTED )?PRIVATE KEY-----.*?" + rb"-----END (?:RSA |EC |DSA |ENCRYPTED )?PRIVATE KEY-----", re.DOTALL, ) @@ -187,9 +187,7 @@ def _validate_security_options(options: ServerOptions) -> x509.Certificate | Non try: private_key = serialization.load_pem_private_key(key_match.group(), password=None) except (TypeError, ValueError) as error: - raise ValueError( - f"Client PEM '{client_path}' must contain a valid, unencrypted private key." - ) from error + raise ValueError(f"Client PEM '{client_path}' must contain a valid, unencrypted private key.") from error public_format = serialization.PublicFormat.SubjectPublicKeyInfo certificate_key = certificate.public_key().public_bytes(serialization.Encoding.DER, public_format) @@ -203,9 +201,7 @@ def _validate_security_options(options: ServerOptions) -> x509.Certificate | Non pass else: if ExtendedKeyUsageOID.CLIENT_AUTH not in extended_key_usage: - raise ValueError( - f"Client certificate '{client_path}' does not allow TLS client authentication." - ) + raise ValueError(f"Client certificate '{client_path}' does not allow TLS client authentication.") if security.ca_certificate_path: ca_path = security.ca_certificate_path diff --git a/tests/certificates.py b/tests/certificates.py index b8014ad..53fe85a 100644 --- a/tests/certificates.py +++ b/tests/certificates.py @@ -132,9 +132,7 @@ def issue_certificate(common_name, extended_key_usage, subject_alternative_name= server_key, server_certificate = issue_certificate( "localhost", [ExtendedKeyUsageOID.SERVER_AUTH, ExtendedKeyUsageOID.CLIENT_AUTH], - x509.SubjectAlternativeName( - [x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))] - ), + x509.SubjectAlternativeName([x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), ) client_key, client_certificate = issue_certificate( "embedded-admin", diff --git a/tests/test_process_exit.py b/tests/test_process_exit.py index bf060d2..74080ac 100644 --- a/tests/test_process_exit.py +++ b/tests/test_process_exit.py @@ -35,6 +35,7 @@ def test_reports_unexpected_process_exit(self): events = [] with EmbeddedServer() as server: + def on_exit(event): events.append(event) callback_finished.set() @@ -62,9 +63,7 @@ def test_reports_expected_process_exit(self): events = [] with EmbeddedServer() as server: - server.add_server_process_exited( - lambda event: (events.append(event), callback_finished.set()) - ) + server.add_server_process_exited(lambda event: (events.append(event), callback_finished.set())) server.start_server(options) process_id = server.get_server_process_id() server.stop_server() diff --git a/tests/test_secured_basic.py b/tests/test_secured_basic.py index 2abd52b..e1dcdc1 100644 --- a/tests/test_secured_basic.py +++ b/tests/test_secured_basic.py @@ -61,9 +61,7 @@ def test_secured_embedded_with_certificate_exec(self): server_pfx, client_pem, ca_crt = generate_separate_server_and_client_certificates(temp_dir) certificate_loader = Path(temp_dir, "load_certificate.py") certificate_loader.write_text( - "import pathlib\n" - "import sys\n" - "sys.stdout.buffer.write(pathlib.Path(sys.argv[1]).read_bytes())\n", + "import pathlib\n" "import sys\n" "sys.stdout.buffer.write(pathlib.Path(sys.argv[1]).read_bytes())\n", encoding="utf-8", ) diff --git a/tests/test_security_validation.py b/tests/test_security_validation.py index fec9738..99e193f 100644 --- a/tests/test_security_validation.py +++ b/tests/test_security_validation.py @@ -33,8 +33,7 @@ def test_client_certificate_and_private_key_must_match(self): _, second_certificate = self._create_certificate([ExtendedKeyUsageOID.CLIENT_AUTH]) client_pem = Path(directory, "client.pem") client_pem.write_bytes( - self._private_key_pem(first_key) - + second_certificate.public_bytes(serialization.Encoding.PEM) + self._private_key_pem(first_key) + second_certificate.public_bytes(serialization.Encoding.PEM) ) options = self._secured_options(client_pem) @@ -45,9 +44,7 @@ def test_declared_eku_must_allow_client_authentication(self): with tempfile.TemporaryDirectory() as directory: key, certificate = self._create_certificate([ExtendedKeyUsageOID.SERVER_AUTH]) client_pem = Path(directory, "client.pem") - client_pem.write_bytes( - self._private_key_pem(key) + certificate.public_bytes(serialization.Encoding.PEM) - ) + client_pem.write_bytes(self._private_key_pem(key) + certificate.public_bytes(serialization.Encoding.PEM)) options = self._secured_options(client_pem) with self.assertRaisesRegex(ValueError, "does not allow TLS client authentication"): @@ -57,9 +54,7 @@ def test_ca_path_must_contain_a_certificate(self): with tempfile.TemporaryDirectory() as directory: key, certificate = self._create_certificate([ExtendedKeyUsageOID.CLIENT_AUTH]) client_pem = Path(directory, "client.pem") - client_pem.write_bytes( - self._private_key_pem(key) + certificate.public_bytes(serialization.Encoding.PEM) - ) + client_pem.write_bytes(self._private_key_pem(key) + certificate.public_bytes(serialization.Encoding.PEM)) ca_path = Path(directory, "ca.crt") ca_path.write_text("not a certificate", encoding="utf-8") diff --git a/tests/test_startup_errors.py b/tests/test_startup_errors.py index a523da6..c740a62 100644 --- a/tests/test_startup_errors.py +++ b/tests/test_startup_errors.py @@ -46,8 +46,7 @@ def test_startup_timeout_has_a_distinct_exception(self): server_directory = Path(directory, "Server") server_directory.mkdir() Path(server_directory, "Raven.Server.dll").write_text( - "import time\n" - "time.sleep(60)\n", + "import time\n" "time.sleep(60)\n", encoding="utf-8", ) From 3fe4db05c00e4f1eb1a238215b9231e6140abcb9 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 29 Jul 2026 20:04:30 +0200 Subject: [PATCH 32/35] RavenDB-27140 Add certificate key identifiers --- labs/04_embedded_secured.py | 2 ++ tests/certificates.py | 6 ++++++ tests/test_security_validation.py | 25 ++++++++++++++++++++++++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/labs/04_embedded_secured.py b/labs/04_embedded_secured.py index c96075d..419e272 100644 --- a/labs/04_embedded_secured.py +++ b/labs/04_embedded_secured.py @@ -65,6 +65,8 @@ def _demo_certificates(directory): .add_extension( x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH, ExtendedKeyUsageOID.CLIENT_AUTH]), critical=False ) + .add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False) + .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(key.public_key()), critical=False) .sign(key, hashes.SHA256()) ) cert_pem = cert.public_bytes(serialization.Encoding.PEM) diff --git a/tests/certificates.py b/tests/certificates.py index 53fe85a..db86c03 100644 --- a/tests/certificates.py +++ b/tests/certificates.py @@ -50,6 +50,8 @@ def generate_self_signed_certificates(directory): x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH, ExtendedKeyUsageOID.CLIENT_AUTH]), critical=False, ) + .add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False) + .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(key.public_key()), critical=False) .sign(key, hashes.SHA256()) ) @@ -96,6 +98,8 @@ def generate_separate_server_and_client_certificates(directory): ), critical=True, ) + .add_extension(x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), critical=False) + .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), critical=False) .sign(ca_key, hashes.SHA256()) ) @@ -124,6 +128,8 @@ def issue_certificate(common_name, extended_key_usage, subject_alternative_name= critical=True, ) .add_extension(x509.ExtendedKeyUsage(extended_key_usage), critical=False) + .add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False) + .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), critical=False) ) if subject_alternative_name: builder = builder.add_extension(subject_alternative_name, critical=False) diff --git a/tests/test_security_validation.py b/tests/test_security_validation.py index 99e193f..74ffa02 100644 --- a/tests/test_security_validation.py +++ b/tests/test_security_validation.py @@ -6,13 +6,36 @@ from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID +from cryptography.hazmat.primitives.serialization import pkcs12 +from cryptography.x509.oid import ExtendedKeyUsageOID, ExtensionOID, NameOID from ravendb_embedded import ServerOptions from ravendb_embedded.raven_server_runner import RavenServerRunner +from tests.certificates import generate_separate_server_and_client_certificates class TestSecurityValidation(TestCase): + def test_generated_certificate_chain_has_matching_key_identifiers(self): + with tempfile.TemporaryDirectory() as directory: + server_pfx, client_pem, _ = generate_separate_server_and_client_certificates(directory) + _, server_certificate, ca_certificates = pkcs12.load_key_and_certificates( + Path(server_pfx).read_bytes(), None + ) + ca_certificate = ca_certificates[0] + client_pem_bytes = Path(client_pem).read_bytes() + client_certificate = x509.load_pem_x509_certificate( + client_pem_bytes[client_pem_bytes.index(b"-----BEGIN CERTIFICATE-----") :] + ) + + ca_key_identifier = ca_certificate.extensions.get_extension_for_oid( + ExtensionOID.SUBJECT_KEY_IDENTIFIER + ).value.digest + for certificate in (server_certificate, client_certificate): + authority_key_identifier = certificate.extensions.get_extension_for_oid( + ExtensionOID.AUTHORITY_KEY_IDENTIFIER + ).value.key_identifier + self.assertEqual(authority_key_identifier, ca_key_identifier) + def test_secured_requires_an_explicit_client_certificate(self): with self.assertRaisesRegex(ValueError, "client_pem_certificate_path is required"): ServerOptions().secured("server.pfx") From b8f140da29d5473118ebb85fe73ee26797d67135 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 29 Jul 2026 20:29:04 +0200 Subject: [PATCH 33/35] RavenDB-27140 Preserve configuration defaults --- README.md | 38 +++++++------------ labs/01-embedded-zero-config.md | 9 ++--- labs/01_embedded_zero_config.py | 1 - labs/02-embedded-external-server.md | 1 - labs/02_embedded_external_server.py | 1 - labs/03-on-demand-server.md | 1 - labs/03_on_demand_server.py | 1 - labs/04-embedded-secured.md | 1 - labs/04_embedded_secured.py | 1 - labs/05-embedded-persistent.md | 6 +-- labs/05_embedded_persistent.py | 1 - labs/06-embedded-lifecycle.md | 1 - labs/06_embedded_lifecycle.py | 1 - ravendb_embedded/options.py | 14 +++++-- ravendb_embedded/raven_server_runner.py | 6 --- tests/test_basic.py | 36 +++++++++--------- tests/test_licensing.py | 11 +----- tests/test_options.py | 18 +++------ .../test_runtime_framework_version_matcher.py | 2 +- 19 files changed, 56 insertions(+), 94 deletions(-) diff --git a/README.md b/README.md index 371e475..5780642 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,6 @@ runtime: from ravendb_embedded import EmbeddedServer, ServerOptions options = ServerOptions() -options.accept_eula = True # Set only after reviewing the RavenDB EULA. with EmbeddedServer() as server: server.start_server(options) @@ -68,7 +67,6 @@ self-contained RavenDB build: from ravendb_embedded import EmbeddedServer, ServerOptions options = ServerOptions() -options.accept_eula = True options.with_auto_downloaded_server() options.data_directory = "./data/RavenDB" options.logs_path = "./logs/RavenDB" @@ -117,7 +115,6 @@ Download and extract the Server package for the target platform, then point the from ravendb_embedded import EmbeddedServer, ServerOptions options = ServerOptions() -options.accept_eula = True options.with_external_server("/path/to/extracted/Server") with EmbeddedServer() as server: @@ -145,18 +142,19 @@ Create a `ServerOptions` instance before starting the server: Construct options with `ServerOptions()`. The misleading `ServerOptions.INSTANCE()` constructor alias is deprecated and will be removed in a future release. -- `data_directory`: where database data is stored; defaults to `./RavenDB` in the application's - current working directory. +- `data_directory`: where database data is stored; retains the existing + `/RavenDB` default. Set it explicitly for applications that need a writable + or application-owned location. - `logs_path`: where RavenDB writes its logs; defaults to `/Logs` and follows a changed `data_directory` until you set `logs_path` explicitly. -- `accept_eula`: must be set to `True` explicitly after reviewing the RavenDB EULA. +- `accept_eula`: controls the RavenDB EULA command-line setting and defaults to `True`. - `server_url`: address to bind; the default uses localhost and a free port. - `dot_net_path`: path to `dotnet` for bundled mode when it is not on `PATH`. - `command_line_args`: additional [RavenDB server arguments](https://ravendb.net/docs/article-page/latest/csharp/server/configuration/command-line-arguments). -- `framework_version`: defaults to `auto`, which reads the server runtime configuration and selects - a compatible installed .NET patch. Set an exact version for advanced setups, or `None`/`""` to - use the dotnet host's normal roll-forward behavior. +- `framework_version`: defaults to `""`, preserving the dotnet host's normal runtime selection and + roll-forward behavior. Set it to `auto` to read the server runtime configuration and select a + compatible installed patch, or set an exact version for advanced setups. - `graceful_shutdown_timeout`: how long to wait before terminating the child process. - `process_kill_timeout`: how long to wait for the process after terminating or killing it. - `max_server_startup_time_duration`: maximum server startup time. @@ -171,7 +169,6 @@ from datetime import timedelta from ravendb_embedded import EmbeddedServer, ServerOptions, ServerStartupTimeoutError options = ServerOptions() -options.accept_eula = True options.max_server_startup_time_duration = timedelta(seconds=15) with EmbeddedServer() as server: @@ -184,10 +181,9 @@ with EmbeddedServer() as server: ### License and EULA -The package never accepts the RavenDB EULA on your behalf. Review the -[RavenDB EULA](https://ravendb.net/legal/ravendb/commercial-license-eula) and set -`options.accept_eula = True` before -starting RavenDB. +`accept_eula` remains enabled by default. Applications can configure it through +`options.accept_eula`. See the +[RavenDB EULA](https://ravendb.net/legal/ravendb/commercial-license-eula) for the applicable terms. Configure a license through `options.licensing`: @@ -211,7 +207,6 @@ Use `ServerOptions.secured()` to start RavenDB over HTTPS: ```python options = ServerOptions() -options.accept_eula = True options.secured( "server.pfx", "client.pem", @@ -263,11 +258,9 @@ instance's process and document stores. ```python first_options = ServerOptions() -first_options.accept_eula = True first_options.data_directory = "./servers/first" second_options = ServerOptions() -second_options.accept_eula = True second_options.data_directory = "./servers/second" with EmbeddedServer() as first, EmbeddedServer() as second: @@ -291,13 +284,10 @@ Runnable walkthrough: [Lab 06 — lifecycle and process monitoring](labs/06-embe ### Persistent data -The default `./RavenDB` directory survives process restarts. Set `data_directory` explicitly when -the application can run from different working directories or when several embedded servers run -in one process. Use a temporary directory for disposable tests. - -Older package versions defaulted to a directory inside the installed Python package. Applications -that need to reuse data created there should point `data_directory` at that existing location -explicitly before upgrading their storage layout. +The default data directory remains `RavenDB` inside the installed package directory. Set +`data_directory` explicitly when the package directory may be read-only, the application owns its +storage layout, or several embedded servers run in one process. Use a temporary directory for +disposable tests. Runnable walkthrough: [Lab 05 — persistent data](labs/05-embedded-persistent.md). diff --git a/labs/01-embedded-zero-config.md b/labs/01-embedded-zero-config.md index fbbf034..cbc0b16 100644 --- a/labs/01-embedded-zero-config.md +++ b/labs/01-embedded-zero-config.md @@ -20,7 +20,6 @@ The core is just: from ravendb_embedded import EmbeddedServer, ServerOptions options = ServerOptions() -options.accept_eula = True with EmbeddedServer() as server: server.start_server(options) @@ -36,10 +35,10 @@ with EmbeddedServer() as server: server (`Raven.Server.dll`) that ships inside the wheel. 2. `RavenServerRunner` launches it as **`dotnet Raven.Server.dll ...`**, using the `dotnet` found on your `PATH` (`ServerOptions.dot_net_path`). -3. `ServerOptions.framework_version` defaults to `auto`. The package reads the bundled - `Raven.Server.runtimeconfig.json`, finds the required .NET line, and selects a compatible - installed patch. The host does **not** roll forward across major versions, which is why a - net10.0 server still requires .NET 10. +3. `ServerOptions.framework_version` defaults to `""`, leaving runtime selection and roll-forward + to the dotnet host. Set it to `auto` when you want the package to read + `Raven.Server.runtimeconfig.json` and select a compatible installed patch. A net10.0 server + still requires .NET 10. 4. The one exception: point the server at a **self-contained** build (an apphost `Raven.Server` with the runtime bundled) and it runs **without `dotnet`**. That is Lab 02. diff --git a/labs/01_embedded_zero_config.py b/labs/01_embedded_zero_config.py index 15611fa..3526c97 100644 --- a/labs/01_embedded_zero_config.py +++ b/labs/01_embedded_zero_config.py @@ -18,7 +18,6 @@ def main() -> None: with tempfile.TemporaryDirectory() as work_dir: options = ServerOptions() - options.accept_eula = True options.data_directory = str(Path(work_dir, "RavenDB")) options.logs_path = str(Path(work_dir, "Logs")) diff --git a/labs/02-embedded-external-server.md b/labs/02-embedded-external-server.md index 45b37d3..3f8f17f 100644 --- a/labs/02-embedded-external-server.md +++ b/labs/02-embedded-external-server.md @@ -24,7 +24,6 @@ The core is: from ravendb_embedded import EmbeddedServer, ServerOptions options = ServerOptions() -options.accept_eula = True options.with_external_server("/path/to/extracted/Server") # a self-contained build with EmbeddedServer() as server: server.start_server(options) diff --git a/labs/02_embedded_external_server.py b/labs/02_embedded_external_server.py index 77f3b54..a004c02 100644 --- a/labs/02_embedded_external_server.py +++ b/labs/02_embedded_external_server.py @@ -26,7 +26,6 @@ def main() -> None: with tempfile.TemporaryDirectory() as work: options = ServerOptions() - options.accept_eula = True options.data_directory = str(Path(work, "data")) options.logs_path = str(Path(work, "logs")) # Bogus on purpose: a self-contained build must never call `dotnet`, so if it still boots the no-.NET path works. diff --git a/labs/03-on-demand-server.md b/labs/03-on-demand-server.md index f807b11..cc46631 100644 --- a/labs/03-on-demand-server.md +++ b/labs/03-on-demand-server.md @@ -22,7 +22,6 @@ The complete example is [`03_on_demand_server.py`](03_on_demand_server.py). The from ravendb_embedded import EmbeddedServer, ServerOptions options = ServerOptions() -options.accept_eula = True options.with_auto_downloaded_server() # download + cache a self-contained server on first use with EmbeddedServer() as server: diff --git a/labs/03_on_demand_server.py b/labs/03_on_demand_server.py index 4b956a8..2bf2079 100644 --- a/labs/03_on_demand_server.py +++ b/labs/03_on_demand_server.py @@ -17,7 +17,6 @@ def main() -> None: with tempfile.TemporaryDirectory() as work: options = ServerOptions() - options.accept_eula = True options.dot_net_path = "__no_dotnet__" options.with_auto_downloaded_server() options.data_directory = str(Path(work, "data")) diff --git a/labs/04-embedded-secured.md b/labs/04-embedded-secured.md index 181b353..b478edd 100644 --- a/labs/04-embedded-secured.md +++ b/labs/04-embedded-secured.md @@ -19,7 +19,6 @@ from ravendb_embedded import EmbeddedServer, ServerOptions from ravendb_embedded.options import DatabaseOptions options = ServerOptions() -options.accept_eula = True options.secured(server_pfx_path, client_pem_path, ca_certificate_path=ca_crt_path) with EmbeddedServer() as server: diff --git a/labs/04_embedded_secured.py b/labs/04_embedded_secured.py index 419e272..127a0ee 100644 --- a/labs/04_embedded_secured.py +++ b/labs/04_embedded_secured.py @@ -92,7 +92,6 @@ def main() -> None: server_pfx, client_pem, ca_crt = _demo_certificates(work) options = ServerOptions() - options.accept_eula = True options.secured(server_pfx, client_pem, ca_certificate_path=ca_crt) options.data_directory = str(Path(work, "RavenDB")) options.logs_path = str(Path(work, "Logs")) diff --git a/labs/05-embedded-persistent.md b/labs/05-embedded-persistent.md index a1d7063..5bf6e7a 100644 --- a/labs/05-embedded-persistent.md +++ b/labs/05-embedded-persistent.md @@ -18,7 +18,6 @@ from ravendb_embedded import EmbeddedServer, ServerOptions def options(data_directory, logs_path): o = ServerOptions() - o.accept_eula = True o.data_directory = data_directory # a fixed folder you reuse across restarts o.logs_path = logs_path return o @@ -38,8 +37,9 @@ with EmbeddedServer() as server: ## Notes -- The default `data_directory` is `./RavenDB` under the current working directory. Set it - explicitly when the application can start from different directories or needs a fixed location. +- The default `data_directory` remains `RavenDB` inside the installed package directory. Set it + explicitly when the package directory may be read-only or the application needs a fixed, + application-owned location. - `get_document_store("Lab")` reuses the existing database on the second run; it only creates one when it is missing. diff --git a/labs/05_embedded_persistent.py b/labs/05_embedded_persistent.py index 1e97fc9..18de0d0 100644 --- a/labs/05_embedded_persistent.py +++ b/labs/05_embedded_persistent.py @@ -16,7 +16,6 @@ def _options(data_directory, logs_path): options = ServerOptions() - options.accept_eula = True options.data_directory = data_directory options.logs_path = logs_path return options diff --git a/labs/06-embedded-lifecycle.md b/labs/06-embedded-lifecycle.md index ed3e999..a72fe1d 100644 --- a/labs/06-embedded-lifecycle.md +++ b/labs/06-embedded-lifecycle.md @@ -27,7 +27,6 @@ from threading import Event from ravendb_embedded import EmbeddedServer, ServerOptions options = ServerOptions() -options.accept_eula = True exit_observed = Event() exit_events = [] diff --git a/labs/06_embedded_lifecycle.py b/labs/06_embedded_lifecycle.py index c07ff56..d54b7ea 100644 --- a/labs/06_embedded_lifecycle.py +++ b/labs/06_embedded_lifecycle.py @@ -10,7 +10,6 @@ def main() -> None: with tempfile.TemporaryDirectory() as work: options = ServerOptions() - options.accept_eula = True options.data_directory = str(Path(work, "data")) options.logs_path = str(Path(work, "logs")) diff --git a/ravendb_embedded/options.py b/ravendb_embedded/options.py index 493a868..746f213 100644 --- a/ravendb_embedded/options.py +++ b/ravendb_embedded/options.py @@ -52,16 +52,18 @@ def __init__(self): class ServerOptions: BASE_MODULE_DIRECTORY = str(Path(__file__).parent) DEFAULT_SERVER_LOCATION = os.path.join(BASE_MODULE_DIRECTORY, CopyServerFromNugetProvider.SERVER_FILES) + _DEFAULT_DATA_DIRECTORY = BASE_MODULE_DIRECTORY + "/RavenDB" + _DEFAULT_LOGS_PATH = BASE_MODULE_DIRECTORY + "/RavenDB/Logs" def __init__(self): - self.framework_version: Optional[str] = "auto" - self._data_directory: str = str(Path.cwd() / "RavenDB") + self.framework_version: Optional[str] = "" + self._data_directory: str = self._DEFAULT_DATA_DIRECTORY self._logs_path: Optional[str] = None self.provider: ProvideRavenDBServer = CopyServerFromNugetProvider() self.target_server_location: str = self.DEFAULT_SERVER_LOCATION self.dot_net_path: str = "dotnet" self.clear_target_server_location: bool = False - self.accept_eula: bool = False + self.accept_eula: bool = True self.server_url: Optional[str] = None self.graceful_shutdown_timeout: timedelta = timedelta(seconds=30) self.process_kill_timeout: timedelta = timedelta(seconds=5) @@ -80,7 +82,11 @@ def data_directory(self, value: str) -> None: @property def logs_path(self) -> str: - return self._logs_path or str(Path(self._data_directory) / "Logs") + if self._logs_path is not None: + return self._logs_path + if self._data_directory == self._DEFAULT_DATA_DIRECTORY: + return self._DEFAULT_LOGS_PATH + return str(Path(self._data_directory) / "Logs") @logs_path.setter def logs_path(self, value: Optional[str]) -> None: diff --git a/ravendb_embedded/raven_server_runner.py b/ravendb_embedded/raven_server_runner.py index 56cf961..9ae46a5 100644 --- a/ravendb_embedded/raven_server_runner.py +++ b/ravendb_embedded/raven_server_runner.py @@ -27,12 +27,6 @@ class RavenServerRunner: @staticmethod def run(options: ServerOptions) -> subprocess.Popen: - if not options.accept_eula: - raise ValueError( - "RavenDB EULA acceptance is required. Review the RavenDB EULA and set " - "ServerOptions.accept_eula = True before starting the server." - ) - client_certificate = RavenServerRunner._validate_security_options(options) if not options.target_server_location.strip(): diff --git a/tests/test_basic.py b/tests/test_basic.py index ffe2d01..5a18d0c 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,4 +1,3 @@ -import os import shutil import tempfile from concurrent.futures import ThreadPoolExecutor @@ -11,25 +10,23 @@ class BasicTest(TestCase): - def test_default_data_and_log_locations_are_runnable(self): - previous_directory = Path.cwd() + def test_backward_compatible_defaults_are_runnable(self): with tempfile.TemporaryDirectory() as temp_dir: - try: - os.chdir(temp_dir) - options = ServerOptions() - options.accept_eula = True - - with EmbeddedServer() as embedded: - embedded.start_server(options) - with embedded.get_document_store("Defaults") as store: - with store.open_session() as session: - session.store({"works": True}, "defaults/1") - session.save_changes() - - self.assertTrue(Path(temp_dir, "RavenDB").is_dir()) - self.assertTrue(Path(temp_dir, "RavenDB", "Logs").is_dir()) - finally: - os.chdir(previous_directory) + options = ServerOptions() + options.data_directory = str(Path(temp_dir, "RavenDB")) + + self.assertTrue(options.accept_eula) + self.assertEqual("", options.framework_version) + + with EmbeddedServer() as embedded: + embedded.start_server(options) + with embedded.get_document_store("Defaults") as store: + with store.open_session() as session: + session.store({"works": True}, "defaults/1") + session.save_changes() + + self.assertTrue(Path(temp_dir, "RavenDB").is_dir()) + self.assertTrue(Path(temp_dir, "RavenDB", "Logs").is_dir()) def test_close_waits_for_document_store_initialization(self): initialization_started = Event() @@ -127,6 +124,7 @@ def test_embedded(self): server_options.accept_eula = True server_options.data_directory = str(Path(temp_dir, "RavenDB")) server_options.logs_path = str(Path(temp_dir, "Logs")) + server_options.framework_version = "auto" server_options.provider = CopyServerFromNugetProvider() server_options.command_line_args = ["--Features.Availability=Experimental"] embedded.start_server(server_options) diff --git a/tests/test_licensing.py b/tests/test_licensing.py index 9a1d053..4a7db0d 100644 --- a/tests/test_licensing.py +++ b/tests/test_licensing.py @@ -5,17 +5,11 @@ from unittest import TestCase from ravendb_embedded import EmbeddedServer, ServerOptions -from ravendb_embedded.raven_server_runner import RavenServerRunner class TestLicensing(TestCase): - def test_eula_must_be_accepted_explicitly(self): - options = ServerOptions() - - with self.assertRaises(ValueError) as context: - RavenServerRunner.run(options) - - self.assertIn("set ServerOptions.accept_eula = True", str(context.exception)) + def test_eula_acceptance_remains_enabled_by_default(self): + self.assertTrue(ServerOptions().accept_eula) def test_server_receives_all_licensing_options(self): with tempfile.TemporaryDirectory() as directory: @@ -33,7 +27,6 @@ def test_server_receives_all_licensing_options(self): ) options = ServerOptions() - options.accept_eula = True options.with_external_server(str(server_directory)) options.dot_net_path = sys.executable options.framework_version = "" diff --git a/tests/test_options.py b/tests/test_options.py index 040a2d7..9ea7c7a 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -1,5 +1,3 @@ -import os -import tempfile from pathlib import Path from unittest import TestCase @@ -7,17 +5,11 @@ class TestServerOptions(TestCase): - def test_default_data_and_logs_are_under_the_working_directory(self): - previous_directory = Path.cwd() - with tempfile.TemporaryDirectory() as directory: - try: - os.chdir(directory) - options = ServerOptions() - - self.assertEqual(str(Path(directory, "RavenDB")), options.data_directory) - self.assertEqual(str(Path(directory, "RavenDB", "Logs")), options.logs_path) - finally: - os.chdir(previous_directory) + def test_default_data_and_logs_remain_under_the_package_directory(self): + options = ServerOptions() + + self.assertEqual(ServerOptions.BASE_MODULE_DIRECTORY + "/RavenDB", options.data_directory) + self.assertEqual(ServerOptions.BASE_MODULE_DIRECTORY + "/RavenDB/Logs", options.logs_path) def test_default_logs_follow_an_explicit_data_directory(self): options = ServerOptions() diff --git a/tests/test_runtime_framework_version_matcher.py b/tests/test_runtime_framework_version_matcher.py index 442a3b9..82066df 100644 --- a/tests/test_runtime_framework_version_matcher.py +++ b/tests/test_runtime_framework_version_matcher.py @@ -11,7 +11,7 @@ class TestRuntimeFrameworkVersionMatcher(TestCase): def test_match_1(self): - self.assertEqual("auto", ServerOptions().framework_version) + self.assertEqual("", ServerOptions().framework_version) options = ServerOptions() From 9c495fe51c21a125b3d88a58601c53706a1d98f6 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 29 Jul 2026 20:58:50 +0200 Subject: [PATCH 34/35] RavenDB-27140 Preserve server-only secured setup --- README.md | 19 ++++++++---- labs/04-embedded-secured.md | 11 ++++--- ravendb_embedded/options.py | 5 ---- ravendb_embedded/raven_server_runner.py | 40 ++++++++++++------------- tests/test_secured_basic.py | 14 +++++++++ tests/test_security_validation.py | 17 +++++++++-- 6 files changed, 68 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 5780642..ce7c119 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,16 @@ unlicensed server would hide a configuration problem. ### HTTPS and client certificates -Use `ServerOptions.secured()` to start RavenDB over HTTPS: +Use `ServerOptions.secured()` with a server PFX to start RavenDB over HTTPS: + +```python +options = ServerOptions() +options.secured("server.pfx") +``` + +This configures the embedded server only. External clients must authenticate with a certificate +trusted by RavenDB. To let `EmbeddedServer` create authenticated `DocumentStore` instances, supply +a client PEM as well: ```python options = ServerOptions() @@ -220,10 +229,10 @@ The client PEM must contain both its certificate and matching unencrypted privat certificate declares Extended Key Usage, it must include TLS Client Authentication. The server PFX is not reused as a client certificate: a server-only certificate may -not have Client Authentication EKU, and its PFX may not contain a suitable client identity. Supply -the client PEM explicitly. `ca_certificate_path` is optional for certificates trusted by the -operating system; provide a CA bundle for private or self-signed server certificates. These files -are validated before the RavenDB process starts. +not have Client Authentication EKU, and its PFX may not contain a suitable client identity. +`ca_certificate_path` is optional for certificates trusted by the operating system; provide a CA +bundle for private or self-signed server certificates. A supplied client PEM and CA bundle are +validated before the RavenDB process starts. When another process supplies the server certificate, use `secured_with_certificate_exec()`: diff --git a/labs/04-embedded-secured.md b/labs/04-embedded-secured.md index b478edd..e1adc1e 100644 --- a/labs/04-embedded-secured.md +++ b/labs/04-embedded-secured.md @@ -30,10 +30,13 @@ with EmbeddedServer() as server: ## Certificates -`secured()` needs a server `.pfx` and a client `.pem`; a password and a CA certificate are -optional. In production you bring your own (for example from RavenDB's setup wizard or your CA). -The lab generates a throwaway self-signed pair so it can run unattended; that generator is demo -code, not something to ship. A RavenDB server certificate needs the right extensions +The server `.pfx` is required. The client `.pem` is needed when `EmbeddedServer` creates +authenticated document stores; a password and a CA certificate are optional. To configure only +the HTTPS server and use clients managed elsewhere, call `options.secured(server_pfx_path)`. + +In production you bring your own certificates (for example from RavenDB's setup wizard or your +CA). The lab generates a throwaway self-signed pair so it can run unattended; that generator is +demo code, not something to ship. A RavenDB server certificate needs the right extensions (digitalSignature key usage, serverAuth/clientAuth EKU, and SANs for how the server is reached), which the lab's generator sets. diff --git a/ravendb_embedded/options.py b/ravendb_embedded/options.py index 746f213..33ebf45 100644 --- a/ravendb_embedded/options.py +++ b/ravendb_embedded/options.py @@ -116,11 +116,6 @@ def secured( ) -> "ServerOptions": if server_pfx_certificate_path is None: raise ValueError("certificate cannot be None") - if not client_pem_certificate_path: - raise ValueError( - "client_pem_certificate_path is required. A server PFX is not reused automatically " - "because it may not contain a client-authentication certificate and private key." - ) if self.security is not None: raise RuntimeError("The security has already been set up for this ServerOptions object") diff --git a/ravendb_embedded/raven_server_runner.py b/ravendb_embedded/raven_server_runner.py index 9ae46a5..6bd608e 100644 --- a/ravendb_embedded/raven_server_runner.py +++ b/ravendb_embedded/raven_server_runner.py @@ -154,11 +154,26 @@ def _validate_security_options(options: ServerOptions) -> x509.Certificate | Non security = options.security if security is None: return None + + if security.ca_certificate_path: + ca_path = security.ca_certificate_path + try: + with open(ca_path, "rb") as ca_file: + ca_data = ca_file.read() + except OSError as error: + raise ValueError(f"Unable to read the CA certificate bundle '{ca_path}': {error}") from error + + ca_certificates = RavenServerRunner._CERTIFICATE_PATTERN.findall(ca_data) + if not ca_certificates: + raise ValueError(f"CA certificate bundle '{ca_path}' does not contain an X.509 certificate.") + try: + for ca_certificate in ca_certificates: + x509.load_pem_x509_certificate(ca_certificate, default_backend()) + except ValueError as error: + raise ValueError(f"CA certificate bundle '{ca_path}' contains an invalid certificate.") from error + if not security.client_pem_certificate_path: - raise ValueError( - "A secured embedded server requires client_pem_certificate_path. " - "The server PFX is not automatically reused as a client certificate." - ) + return None client_path = security.client_pem_certificate_path try: @@ -197,23 +212,6 @@ def _validate_security_options(options: ServerOptions) -> x509.Certificate | Non if ExtendedKeyUsageOID.CLIENT_AUTH not in extended_key_usage: raise ValueError(f"Client certificate '{client_path}' does not allow TLS client authentication.") - if security.ca_certificate_path: - ca_path = security.ca_certificate_path - try: - with open(ca_path, "rb") as ca_file: - ca_data = ca_file.read() - except OSError as error: - raise ValueError(f"Unable to read the CA certificate bundle '{ca_path}': {error}") from error - - ca_certificates = RavenServerRunner._CERTIFICATE_PATTERN.findall(ca_data) - if not ca_certificates: - raise ValueError(f"CA certificate bundle '{ca_path}' does not contain an X.509 certificate.") - try: - for ca_certificate in ca_certificates: - x509.load_pem_x509_certificate(ca_certificate, default_backend()) - except ValueError as error: - raise ValueError(f"CA certificate bundle '{ca_path}' contains an invalid certificate.") from error - return certificate @staticmethod diff --git a/tests/test_secured_basic.py b/tests/test_secured_basic.py index e1dcdc1..8d31270 100644 --- a/tests/test_secured_basic.py +++ b/tests/test_secured_basic.py @@ -12,6 +12,20 @@ class TestSecuredBasic(TestCase): + def test_secured_embedded_server_starts_without_a_managed_client_certificate(self): + with tempfile.TemporaryDirectory() as temp_dir: + server_pfx, _, _ = generate_self_signed_certificates(temp_dir) + with EmbeddedServer() as embedded: + server_options = ServerOptions() + server_options.secured(server_pfx) + server_options.data_directory = str(Path(temp_dir, "RavenDB")) + server_options.logs_path = str(Path(temp_dir, "Logs")) + server_options.provider = CopyServerFromNugetProvider() + + embedded.start_server(server_options) + + self.assertTrue(embedded.get_server_uri().startswith("https://")) + def test_secured_embedded(self): temp_dir = tempfile.mkdtemp() try: diff --git a/tests/test_security_validation.py b/tests/test_security_validation.py index 74ffa02..4255f0d 100644 --- a/tests/test_security_validation.py +++ b/tests/test_security_validation.py @@ -36,9 +36,11 @@ def test_generated_certificate_chain_has_matching_key_identifiers(self): ).value.key_identifier self.assertEqual(authority_key_identifier, ca_key_identifier) - def test_secured_requires_an_explicit_client_certificate(self): - with self.assertRaisesRegex(ValueError, "client_pem_certificate_path is required"): - ServerOptions().secured("server.pfx") + def test_secured_allows_server_only_configuration(self): + options = ServerOptions().secured("server.pfx") + + self.assertEqual(options.security.server_pfx_certificate_path, "server.pfx") + self.assertIsNone(options.security.client_pem_certificate_path) def test_client_pem_must_contain_a_private_key(self): with tempfile.TemporaryDirectory() as directory: @@ -85,6 +87,15 @@ def test_ca_path_must_contain_a_certificate(self): with self.assertRaisesRegex(ValueError, "does not contain an X.509 certificate"): RavenServerRunner.run(options) + def test_ca_is_validated_without_a_managed_client_certificate(self): + with tempfile.TemporaryDirectory() as directory: + ca_path = Path(directory, "ca.crt") + ca_path.write_text("not a certificate", encoding="utf-8") + options = ServerOptions().secured("server.pfx", ca_certificate_path=str(ca_path)) + + with self.assertRaisesRegex(ValueError, "does not contain an X.509 certificate"): + RavenServerRunner.run(options) + @staticmethod def _secured_options(client_pem: Path, ca_path: Path = None) -> ServerOptions: options = ServerOptions() From 4458b589506a42246a12cd5fac74f41a90cd40d3 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Thu, 30 Jul 2026 11:10:42 +0200 Subject: [PATCH 35/35] RavenDB-27140 Prepare 7.2.5.post2 release --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index f366edb..9bcd14d 100644 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ def run(self): include_package_data=True, long_description=open("README.md").read(), long_description_content_type="text/markdown", - version="7.2.5.post1", + version="7.2.5.post2", description="RavenDB Embedded library to run RavenDB in an embedded way", author="RavenDB", author_email="support@ravendb.net",