diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b8a5b00 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + github-actions: + patterns: + - "*" + + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1d8a1d2..58341d9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,30 +9,27 @@ on: jobs: test: + # Path A: embedded framework-dependent server -> needs a system .NET. The 7.2 server + # targets net10.0 (7.1 was net8.0), so we install .NET 10 explicitly and assert the + # bundled server really needs it (no more green-by-luck on a preinstalled runtime). runs-on: ${{ matrix.os }} - env: - # .NET 8 (the server's target) is exercised by default resolution; .NET 10 must be - # forced since runners preinstall .NET 8 and would otherwise win. tests/pin_framework_version. - RAVENDB_TEST_FRAMEWORK_VERSION: ${{ matrix.dotnet == '10.0' && '10.0.x' || '' }} strategy: fail-fast: false - # Latest Python only (the ravendb client requires 3.10+); full OS x .NET matrix. matrix: os: [ubuntu-latest, windows-latest] - dotnet: ["8.0", "10.0"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Set up Python 3.13 - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.13" - - name: Set up .NET ${{ matrix.dotnet }} - uses: actions/setup-dotnet@v4 + - name: Set up .NET 10 + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 with: - dotnet-version: ${{ matrix.dotnet }} + dotnet-version: "10.0" - name: Install package run: | @@ -44,12 +41,57 @@ jobs: pip install black black --check . - # Populates ravendb_embedded/target/nuget via the project's sdist hook (the server the - # tests copy from). setuptools is installed explicitly; modern virtualenvs omit it. + # Downloads + unpacks the bundled (framework-dependent) server into + # ravendb_embedded/target/nuget. setuptools is installed explicitly; modern venvs omit it. - name: Fetch bundled RavenDB server run: | pip install setuptools wheel python setup.py sdist + - name: Assert CI .NET matches the server's required runtime + run: python scripts/check_dotnet_requirement.py + - name: Run tests run: python -m unittest discover -s tests + + - name: Run Lab 01 (embedded zero-config) + run: python labs/01_embedded_zero_config.py + + - name: Run Lab 04 (secured embedded) + run: python labs/04_embedded_secured.py + + - name: Run Lab 05 (persistent data directory) + run: python labs/05_embedded_persistent.py + + self-contained: + # Path B: external self-contained server -> runs the native apphost, no system .NET. + # We do not set up .NET; the lab uses a bogus dot_net_path so a successful run proves the + # apphost path is genuinely dotnet-free even though the runner happens to ship a .NET. + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Set up Python 3.13 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.13" + + - name: Install package + run: | + python -m pip install --upgrade pip + pip install -e . + + - name: Download a self-contained RavenDB server + run: | + curl -fsSL -o ravendb.tar.bz2 "https://hibernatingrhinos.com/downloads/RavenDB%20for%20Linux%20x64/latest?version=7.2" + mkdir server + tar xjf ravendb.tar.bz2 -C server + + - name: Run Lab 02 (self-contained, no system .NET) + run: | + SERVER="$(find server -type d -name Server | head -1)" + RAVENDB_SELF_CONTAINED_SERVER="$SERVER" python labs/02_embedded_external_server.py + + - name: Run Lab 03 (on-demand cached self-contained, no system .NET) + run: python labs/03_on_demand_server.py diff --git a/.gitignore b/.gitignore index 3474397..5f6470d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /dist /ravendb_embedded.egg-info /ravendb_embedded/target +/ravendb_embedded/RavenDB *.pyc *.log *.raven-topology diff --git a/MANIFEST.in b/MANIFEST.in index aaf6e1b..6460748 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,3 @@ graft ravendb_embedded/target/nuget -include README.rst +include README.md recursive-exclude tests * \ No newline at end of file diff --git a/README.md b/README.md index 9f70d56..6e0477e 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,157 @@ +# ravendb-embedded -## Overview -ravendb-embedded is a RavenDB package for running RavenDB in embedded mode. +`ravendb-embedded` runs a real RavenDB server from inside your Python program. You `pip install` +it, start the server in-process, and talk to it with the normal `ravendb` client. There is no +separate server to install, configure, or keep running: the server's lifetime follows your +process. + +Reach for it when you want: + +- **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. ```python from ravendb_embedded import EmbeddedServer -EmbeddedServer().start_server() -with EmbeddedServer().get_document_store("Embedded") as store: - with store.open_session() as session: - session.store(User(name="Ilay", age=4)) - session.save_changes() -``` +with EmbeddedServer() as server: + server.start_server() + with server.get_document_store("Embedded") as store: + with store.open_session() as session: + session.store({"name": "Ayende"}, "people/1") + session.save_changes() +``` ## Installation -Install from [PyPi](https://pypi.python.org/pypi), as [ravendb-embedded](https://pypi.python.org/project/ravendb-embedded). + ```bash pip install ravendb-embedded ``` -Install ravendb-embedded from pip will provide you with a copy of RavenDB server binaries files as well. + +The install includes a copy of the RavenDB server binaries. Python 3.10+ is required. + +## The .NET requirement (read this) + +The bundled server is a .NET application, so a matching **.NET runtime** must be on the machine. +The required version tracks the bundled server: + +| `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. + +If the machine cannot or should not have .NET, use the self-contained path under +[Run without installing .NET](#run-without-installing-net) below. ## Usage -#### Start a server -To start RavenDB server, call `start_server()` method from `EmbeddedServer` instance. -```python -from ravendb_embedded import EmbeddedServer -ravendb_server = EmbeddedServer() -ravendb_server.start_server() -``` -To be more in control about your server `start_server` method can take `server_options`. +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. -#### ServerOptions -* **framework_version** - The framework version to run the server with. -* **data_directory** - Where to save the database data (if None the files will be saved in RavenDB folder in the base folder). -* **server_url** - The url the server will be opened if None the server will open on local host. -* **dotnet_path** - Where dotnet.exe is located if dotnet in the PATH nothing needed here (If .net core is not installed in your machine -you can download [dotnet binaries](https://www.microsoft.com/net/download/windows) and just put the path to it) -* **command_line_args** - A list of all [server command args](https://ravendb.net/docs/article-page/6.0/csharp/server/configuration/command-line-arguments). ```python from ravendb_embedded import EmbeddedServer, ServerOptions -server_options = ServerOptions(data_directory="MYPATH/RavenDBDataDir") -EmbeddedServer().start_server(server_options) +options = ServerOptions() +options.data_directory = "MYPATH/RavenDBDataDir" # optional; defaults to a local RavenDB folder + +with EmbeddedServer() as server: + server.start_server(options) + with server.get_document_store("MyDb") as store: + ... # ordinary ravendb client code ``` ---- -##### Security -There are options to make ravendb secured in ravendb-embedded:
- -`secured(server_pfx_certificate_path, client_pem_certificate_path, server_pfx_certificate_password=None, ca_certificate_path = None)` -- For this option you will put path to a .pfx and .pem files and a password/ca cert if you have one. -- Server certificate password and CA cert file are optional arguments. Minimal setup requires both .pfx server and .pem client certificates. - ```python - from ravendb_embedded import EmbeddedServer, ServerOptions - - server_options = ServerOptions() - server_options.secured("PATH_TO_SERVER_PFX_CERT_FILE", "PATH_TO_CLIENT_PEM_CERT") - EmbeddedServer.start_server(server_options) - ``` ---- -#### Get Document Store -After initialize and start the server we can use `get_document_store` method to be able to get a DocumentStore -and start work with RavenDB as normal. + +Runnable walkthrough: [`labs/01-embedded-zero-config.md`](labs/01-embedded-zero-config.md). + +### Run without installing .NET + +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`. ```python -from ravendb_embedded import EmbeddedServer +from ravendb_embedded import EmbeddedServer, ServerOptions -ravendb_server = EmbeddedServer() -ravendb_server.start_server() +options = ServerOptions() +options.with_external_server("/path/to/extracted/Server") # a self-contained build -with ravendb_server.get_document_store("Test") as store: -# Your code here +with EmbeddedServer() as server: + server.start_server(options) + with server.get_document_store("MyDb") as store: + ... ``` ---- -##### DatabaseOptions -* **database_name** - The name of the database -* **skip_creating_database** - `get_document_store` will create a new database if the database is not exists, -if this option if True we won't create the database (Default False). +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). + +Or skip the manual download and let the driver fetch and cache one for you on first use: ```python -# In this example we won't create the Test database if not exists will raise an exception -from ravendb_embedded import EmbeddedServer, DatabaseOptions +options = ServerOptions() +options.with_auto_downloaded_server() # downloads + caches a self-contained server, no .NET needed +``` -ravendb_server = EmbeddedServer() -ravendb_server.start_server() +Walkthrough: [`labs/03-on-demand-server.md`](labs/03-on-demand-server.md). -database_options = DatabaseOptions.from_database_name("Test") -database_options.skip_creating_database = True +### Don't manage a server at all (tests) -with ravendb_server.get_document_store_from_options(database_options) as store: -# Your code here -``` +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. -#### Open RavenDB studio in the browser -To open RavenDB studio from ravendb-embedded you can use `open_studio_in_browser` method and the studio will open automatically -one your default browser. +## Configuration -```python -from ravendb_embedded import EmbeddedServer -ravendb_server = EmbeddedServer() -ravendb_server.start_server() +### `ServerOptions` + +Create `ServerOptions()` and set attributes: + +- `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). -ravendb_server.open_studio_in_browser() +### Security + +Secure the server with `ServerOptions.secured()`: + +```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, +) ``` + +Runnable example (HTTPS + client-certificate auth): +[`labs/04-embedded-secured.md`](labs/04-embedded-secured.md). + +### Working with data + +`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. + +Call `open_studio_in_browser()` to open RavenDB Studio in your default browser. + +## Labs + +The `labs/` folder holds runnable, self-checking guides, one per usage case above. Start at +[`labs/README.md`](labs/README.md). diff --git a/README.rst b/README.rst deleted file mode 100644 index e706ad5..0000000 --- a/README.rst +++ /dev/null @@ -1,84 +0,0 @@ -======== -Overview -======== - -``ravendb-embedded`` is a RavenDB package for running RavenDB in embedded mode. - -.. code-block:: python - - EmbeddedServer().start_server() - with EmbeddedServer().get_document_store("Embedded") as store: - with store.open_session() as session: - session.store(User(name="Ilay", age=4)) - session.save_changes() - -============ -Installation -============ - -Install ``ravendb-embedded`` from `PyPi `_ using: - -.. code-block:: bash - - pip install ravendb-embedded - -Installing ``ravendb-embedded`` from pip will also provide you with a copy of the RavenDB server binary files. - -======== -Usage -======== - -Start a server --------------- - -To start the RavenDB server, call the ``start_server()`` method from an ``EmbeddedServer`` instance. - -.. code-block:: python - - EmbeddedServer.start_server() - -For more control over your server, you can pass ``server_options`` to the ``start_server()`` method. - -ServerOptions -------------- - -- ``framework_version``: The framework version to run the server with. -- ``data_directory``: Where to save the database data (if None, the files will be saved in the RavenDB folder in the base folder). -- ``server_url``: The URL the server will be opened on (if None, the server will open on localhost). -- ``dotnet_path``: The location of ``dotnet.exe`` (if .NET Core is not installed on your machine, you can download `dotnet binaries `_ and provide the path). -- ``command_line_args``: A list of all `server command arguments `_. - -.. code-block:: python - - server_options = ServerOptions(data_directory="MYPATH/RavenDBDataDir") - EmbeddedServer().start_server(server_options) - -Security --------- - -You can secure ``ravendb-embedded`` using the ``secured()`` method: - -.. code-block:: python - - secured(server_pfx_certificate_path, client_pem_certificate_path, server_pfx_certificate_password=None, ca_certificate_path=None) - -- Provide the path to ``.pfx`` and ``.pem`` files, and optionally a password and CA certificate file. -- Minimal setup requires both a ``.pfx`` server and a ``.pem`` client certificate. - -Get Document Store ------------------- - -After initializing and starting the server, you can use the ``get_document_store`` method to obtain a ``DocumentStore`` and start working with RavenDB as usual. - -``get_document_store`` method can take either just the ``database_name`` or ``DatabaseOptions``. - -DatabaseOptions ---------------- - -- ``database_name``: The name of the database. -- ``skip_creating_database``: ``get_document_store`` will create a new database if it does not exist. If this option is set to True, the database won't be created (Default False). - -Open RavenDB Studio in the Browser ------------------------------------ - -To open RavenDB Studio from ``ravendb-embedded``, use the ``open_studio_in_browser`` method, and the studio will open automatically in your default browser. diff --git a/labs/01-embedded-zero-config.md b/labs/01-embedded-zero-config.md new file mode 100644 index 0000000..d07ca6c --- /dev/null +++ b/labs/01-embedded-zero-config.md @@ -0,0 +1,47 @@ +# Lab 01: Embedded, zero-config + +**For:** local development or CI on a machine that already has .NET installed. You want +`pip install ravendb-embedded` and a working RavenDB server with nothing set up on the side. + +**Requirement:** RavenDB 7.2.x needs **.NET 10** (7.1.x needed .NET 8). Verify with +`dotnet --list-runtimes` and look for `Microsoft.NETCore.App 10.0.x`. + +## Run it + +```bash +pip install ravendb-embedded +python labs/01_embedded_zero_config.py +``` + +The complete, runnable example is [`01_embedded_zero_config.py`](01_embedded_zero_config.py). +The core is just: + +```python +from ravendb_embedded import EmbeddedServer + +with EmbeddedServer() as server: + server.start_server() + with server.get_document_store("Lab") as store: + with store.open_session() as session: + session.store({"name": "Ayende"}, "people/1") + session.save_changes() +``` + +## How the .NET runtime is picked (under the hood) + +1. The default provider (`CopyServerFromNugetProvider`) unpacks the **framework-dependent** + 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"). +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. + +## Takeaway + +This is the most convenient path, but it buys a hard dependency on a system-wide .NET in a +specific major version. If you would rather not manage .NET, use Lab 02 (external +self-contained server) or Lab 03 (attach to a server you run yourself, e.g. via Docker). diff --git a/labs/01_embedded_zero_config.py b/labs/01_embedded_zero_config.py new file mode 100644 index 0000000..3526c97 --- /dev/null +++ b/labs/01_embedded_zero_config.py @@ -0,0 +1,39 @@ +"""Lab 01: Embedded, zero-config. + +For: local development or CI on a machine that already has .NET installed. +You want `pip install ravendb-embedded` and a working RavenDB server, no extra setup. + +Requirement: RavenDB 7.2.x needs .NET 10 (7.1.x needed .NET 8). +Check your runtime with: dotnet --list-runtimes (look for Microsoft.NETCore.App 10.0.x) + +Run: python labs/01_embedded_zero_config.py +""" + +import tempfile +from pathlib import Path + +from ravendb_embedded import EmbeddedServer, ServerOptions + + +def main() -> None: + with tempfile.TemporaryDirectory() as work_dir: + options = ServerOptions() + options.data_directory = str(Path(work_dir, "RavenDB")) + options.logs_path = str(Path(work_dir, "Logs")) + + with EmbeddedServer() as 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") + session.save_changes() + + with store.open_session() as session: + loaded = session.load("people/1", dict) + assert loaded["name"] == "Ayende", loaded + + print("Lab 01 OK: embedded server started, wrote and read a document.") + + +if __name__ == "__main__": + main() diff --git a/labs/02-embedded-external-server.md b/labs/02-embedded-external-server.md new file mode 100644 index 0000000..184796a --- /dev/null +++ b/labs/02-embedded-external-server.md @@ -0,0 +1,45 @@ +# Lab 02: External self-contained server (no system .NET) + +**For:** machines or CI that do not have .NET installed. You bring a **self-contained** +RavenDB server (it bundles the .NET runtime); the driver runs its native apphost directly and +never calls `dotnet`. + +## Get a self-contained server + +Download the Server package for your platform from the RavenDB downloads page: +. You get an archive; extract it, and the server files are in its +`Server/` subfolder. + +## Run it + +```bash +pip install ravendb-embedded +RAVENDB_SELF_CONTAINED_SERVER=/path/to/extracted/Server python labs/02_embedded_external_server.py +``` + +The complete example is [`02_embedded_external_server.py`](02_embedded_external_server.py). +The core is: + +```python +from ravendb_embedded import EmbeddedServer, ServerOptions + +options = ServerOptions() +options.with_external_server("/path/to/extracted/Server") # a self-contained build +with EmbeddedServer() as server: + server.start_server(options) + with server.get_document_store("Lab") as store: + ... # ordinary RavenDB client code, with no .NET on the machine +``` + +## How it decides not to use `dotnet` + +`ExternalServerProvider` reads `Raven.Server.runtimeconfig.json`. A self-contained build lists +`includedFrameworks` (its bundled runtime), so the driver marks it "run the native apphost" +and launches `Raven.Server` (or `Raven.Server.exe` on Windows) directly, with **no `dotnet`**. +A framework-dependent build (only a `framework` reference, no bundled runtime) still runs via +`dotnet Raven.Server.dll` and needs a system .NET, which is Lab 01. + +## Takeaway + +No .NET on the box, at the cost of fetching and caching the server build yourself. If you would +rather not manage a server at all, run one in a container and attach to it: Lab 03. diff --git a/labs/02_embedded_external_server.py b/labs/02_embedded_external_server.py new file mode 100644 index 0000000..a004c02 --- /dev/null +++ b/labs/02_embedded_external_server.py @@ -0,0 +1,49 @@ +"""Lab 02: External self-contained server (no system .NET). + +For: machines or CI that do NOT have .NET installed. You bring a self-contained RavenDB +server build (it bundles the .NET runtime), and the driver runs its native apphost directly, +never calling `dotnet`. + +Get the Server package for your platform from the RavenDB downloads page +(https://ravendb.net/downloads), extract it; the server files live in the `Server/` subfolder. + +Run: + RAVENDB_SELF_CONTAINED_SERVER=/path/to/extracted/Server python labs/02_embedded_external_server.py +""" + +import os +import sys +import tempfile +from pathlib import Path + +from ravendb_embedded import EmbeddedServer, ServerOptions + +SERVER = os.environ.get("RAVENDB_SELF_CONTAINED_SERVER") or (sys.argv[1] if len(sys.argv) > 1 else None) +if not SERVER: + sys.exit("Set RAVENDB_SELF_CONTAINED_SERVER (or pass a path) to the extracted 'Server' folder. See the header.") + + +def main() -> None: + with tempfile.TemporaryDirectory() as work: + options = ServerOptions() + 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. + options.dot_net_path = "__no_dotnet__" + options.with_external_server(SERVER) + + with EmbeddedServer() as 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") + session.save_changes() + + with store.open_session() as session: + assert session.load("people/1", dict)["name"] == "Ayende" + + print("Lab 02 OK: self-contained server ran with NO system .NET.") + + +if __name__ == "__main__": + main() diff --git a/labs/03-on-demand-server.md b/labs/03-on-demand-server.md new file mode 100644 index 0000000..6054476 --- /dev/null +++ b/labs/03-on-demand-server.md @@ -0,0 +1,51 @@ +# Lab 03: On-demand, cached self-contained server (no .NET) + +**For:** the no-.NET experience of Lab 02 without downloading and extracting a server yourself. +Call `with_auto_downloaded_server()`; on first use the driver fetches a self-contained build for this +platform, caches it, and reuses the cache from then on. A self-contained build bundles its own +runtime, so the server runs its native apphost and never calls `dotnet`. + +## Run it + +```bash +pip install ravendb-embedded +python labs/03_on_demand_server.py +``` + +The complete example is [`03_on_demand_server.py`](03_on_demand_server.py). The core is: + +```python +from ravendb_embedded import EmbeddedServer, ServerOptions + +options = ServerOptions() +options.with_auto_downloaded_server() # download + cache a self-contained server on first use + +with EmbeddedServer() as server: + server.start_server(options) + with server.get_document_store("Lab") as store: + ... # ordinary RavenDB client code, with no .NET on the machine +``` + +`with_auto_downloaded_server(version=None, cache_root=None)` defaults the version to the installed +package's RavenDB line and caches under `~/.cache/ravendb-embedded`. Pass `cache_root` to point +it at a directory your CI restores between runs. + +## Why pulling `latest` is fine (on purpose) + +Fetching the `latest` self-contained build for the version line is deliberate. A self-contained +build bundles its own .NET runtime, so it does not have to match anything on the host: whatever +`latest` returns is a server that just runs. There is no host .NET compatibility matrix to pin +against, which is exactly what makes "grab latest and run" safe here (a framework-dependent build +could not make that promise). The cache then freezes whatever you first pulled, so later runs stay +stable without any extra pinning. + +## Cost to know about + +The first use downloads a self-contained build (100 MB+) and the cache keeps it on disk. Every +run after that is offline and instant. If disk or first-run latency matters, prefer Lab 02 (you +provide the server) or Lab 01 (bundled server, needs .NET). + +## Takeaway + +`with_auto_downloaded_server()` gives the no-.NET path of Lab 02 with zero manual steps: one call, then +ordinary client code. diff --git a/labs/03_on_demand_server.py b/labs/03_on_demand_server.py new file mode 100644 index 0000000..a1811d4 --- /dev/null +++ b/labs/03_on_demand_server.py @@ -0,0 +1,37 @@ +"""Lab 03: On-demand, cached self-contained server (no manual download, no .NET). + +For: Lab 02 (no system .NET) without downloading and extracting a server yourself. Call +`with_auto_downloaded_server()`; on first use the driver fetches a self-contained build for this +platform, caches it, and reuses the cache next time. Because the build is self-contained it runs +its native apphost and never calls `dotnet`. + +Run: python labs/03_on_demand_server.py +""" + +import tempfile +from pathlib import Path + +from ravendb_embedded import EmbeddedServer, ServerOptions + + +def main() -> None: + with tempfile.TemporaryDirectory() as work: + options = ServerOptions() + options.with_auto_downloaded_server() + options.data_directory = str(Path(work, "data")) + options.logs_path = str(Path(work, "logs")) + + with EmbeddedServer() as 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") + session.save_changes() + with store.open_session() as session: + assert session.load("people/1", dict)["name"] == "Ayende" + + print("Lab 03 OK: on-demand self-contained server, cached and run with no system .NET.") + + +if __name__ == "__main__": + main() diff --git a/labs/04-embedded-secured.md b/labs/04-embedded-secured.md new file mode 100644 index 0000000..b478edd --- /dev/null +++ b/labs/04-embedded-secured.md @@ -0,0 +1,43 @@ +# Lab 04: Secured embedded server (HTTPS + client certificate) + +**For:** running the embedded server with TLS and client-certificate authentication instead of +plain HTTP. You bring a server certificate (`.pfx`) and a client certificate (`.pem`); +`ServerOptions.secured()` wires them in, and the driver hands you a store already configured with +the client cert, so your session code does not change. + +## Run it + +```bash +pip install ravendb-embedded +python labs/04_embedded_secured.py +``` + +The complete example is [`04_embedded_secured.py`](04_embedded_secured.py). The core is: + +```python +from ravendb_embedded import EmbeddedServer, ServerOptions +from ravendb_embedded.options import DatabaseOptions + +options = ServerOptions() +options.secured(server_pfx_path, client_pem_path, ca_certificate_path=ca_crt_path) + +with EmbeddedServer() as server: + server.start_server(options) + with server.get_document_store_from_options(DatabaseOptions.from_database_name("Lab")) as store: + # store.urls[0] is now https://...; the client certificate is already attached + ... +``` + +## 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 +(digitalSignature key usage, serverAuth/clientAuth EKU, and SANs for how the server is reached), +which the lab's generator sets. + +## Takeaway + +Securing the embedded server is one `secured()` call plus certificates. Everything after +`get_document_store` is ordinary RavenDB client code, now over HTTPS with client-certificate auth. diff --git a/labs/04_embedded_secured.py b/labs/04_embedded_secured.py new file mode 100644 index 0000000..4e64295 --- /dev/null +++ b/labs/04_embedded_secured.py @@ -0,0 +1,113 @@ +"""Lab 04: Secured embedded server (HTTPS + client-certificate auth). + +For: running the embedded server with TLS and client-certificate authentication instead of plain +HTTP. Point `ServerOptions.secured()` at a server certificate (.pfx) and a client certificate +(.pem); the driver wires the client cert into the store, so your session code is unchanged. + +In production you bring your own certificates. This lab generates a throwaway self-signed pair so +it can run unattended. + +Run: python labs/04_embedded_secured.py +""" + +import datetime +import ipaddress +import shutil +import tempfile +from pathlib import Path + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import pkcs12 +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + +from ravendb_embedded import EmbeddedServer, ServerOptions +from ravendb_embedded.options import DatabaseOptions + + +def _demo_certificates(directory): + """Throwaway self-signed server.pfx + client.pem (demo only; bring your own in production). + + Carries what RavenDB requires of a server certificate: digitalSignature key usage, + serverAuth/clientAuth EKU, and SANs for how the embedded server is reached (localhost, + 127.0.0.1). The same cert doubles as the trusted admin client certificate. + """ + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]) + now = datetime.datetime.now(datetime.timezone.utc) + cert = ( + 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=3650)) + .add_extension( + x509.SubjectAlternativeName([x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), + critical=False, + ) + .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([ExtendedKeyUsageOID.SERVER_AUTH, ExtendedKeyUsageOID.CLIENT_AUTH]), critical=False + ) + .sign(key, hashes.SHA256()) + ) + cert_pem = cert.public_bytes(serialization.Encoding.PEM) + key_pem = key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.TraditionalOpenSSL, serialization.NoEncryption() + ) + server_pfx, client_pem, ca_crt = ( + Path(directory, "server.pfx"), + Path(directory, "client.pem"), + Path(directory, "ca.crt"), + ) + server_pfx.write_bytes( + pkcs12.serialize_key_and_certificates(b"localhost", key, cert, None, serialization.NoEncryption()) + ) + client_pem.write_bytes(key_pem + cert_pem) + ca_crt.write_bytes(cert_pem) + return str(server_pfx), str(client_pem), str(ca_crt) + + +def main() -> None: + work = tempfile.mkdtemp() + try: + server_pfx, client_pem, ca_crt = _demo_certificates(work) + + options = ServerOptions() + 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")) + + with EmbeddedServer() as server: + server.start_server(options) + with server.get_document_store_from_options(DatabaseOptions.from_database_name("Lab")) as store: + assert store.urls[0].startswith("https://"), store.urls + with store.open_session() as session: + session.store({"name": "Ayende"}, "people/1") + session.save_changes() + with store.open_session() as session: + assert session.load("people/1", dict)["name"] == "Ayende" + finally: + shutil.rmtree(work, ignore_errors=True) + + print("Lab 04 OK: secured embedded server over HTTPS with client-certificate auth.") + + +if __name__ == "__main__": + main() diff --git a/labs/05-embedded-persistent.md b/labs/05-embedded-persistent.md new file mode 100644 index 0000000..9a1f869 --- /dev/null +++ b/labs/05-embedded-persistent.md @@ -0,0 +1,48 @@ +# Lab 05: Persistent data directory (data survives restarts) + +**For:** using the embedded server as a durable local database rather than a throwaway. Point +`data_directory` at a fixed folder and your databases and documents live there, still present the +next time you start the server against the same folder. + +## Run it + +```bash +pip install ravendb-embedded +python labs/05_embedded_persistent.py +``` + +The complete example is [`05_embedded_persistent.py`](05_embedded_persistent.py). The core is: + +```python +from ravendb_embedded import EmbeddedServer, ServerOptions + +def options(data_directory, logs_path): + o = ServerOptions() + o.data_directory = data_directory # a fixed folder you reuse across restarts + o.logs_path = logs_path + return o + +# First run: write, then shut down. +with EmbeddedServer() as server: + server.start_server(options(data_directory, logs_path)) + with server.get_document_store("Lab") as store: + ... # store a document + +# Later run against the SAME data_directory: the document is still there. +with EmbeddedServer() as server: + server.start_server(options(data_directory, logs_path)) + with server.get_document_store("Lab") as store: + ... # load it back +``` + +## 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. +- `get_document_store("Lab")` reuses the existing database on the second run; it only creates one + when it is missing. + +## Takeaway + +Embedded is not in-memory. Give it a stable `data_directory` and it behaves like a normal local +RavenDB whose data outlives the process. diff --git a/labs/05_embedded_persistent.py b/labs/05_embedded_persistent.py new file mode 100644 index 0000000..18de0d0 --- /dev/null +++ b/labs/05_embedded_persistent.py @@ -0,0 +1,51 @@ +"""Lab 05: Persistent data directory (data survives restarts). + +For: using the embedded server as a durable local database, not a throwaway. Point +`data_directory` at a fixed folder; the databases and documents live there and are still present +the next time you start the server against the same folder. + +Run: python labs/05_embedded_persistent.py +""" + +import shutil +import tempfile +from pathlib import Path + +from ravendb_embedded import EmbeddedServer, ServerOptions + + +def _options(data_directory, logs_path): + options = ServerOptions() + options.data_directory = data_directory + options.logs_path = logs_path + return options + + +def main() -> None: + root = tempfile.mkdtemp() + data_directory = str(Path(root, "RavenDB")) + logs_path = str(Path(root, "Logs")) + try: + # First run: write a document, then shut the server down (context exit stops it). + with EmbeddedServer() as server: + server.start_server(_options(data_directory, logs_path)) + with server.get_document_store("Lab") as store: + with store.open_session() as session: + session.store({"name": "Ayende"}, "people/1") + session.save_changes() + + # Second run against the SAME data_directory: the document is still there. + with EmbeddedServer() as server: + server.start_server(_options(data_directory, logs_path)) + with server.get_document_store("Lab") as store: + with store.open_session() as session: + loaded = session.load("people/1", dict) + assert loaded is not None and loaded["name"] == "Ayende", loaded + finally: + shutil.rmtree(root, ignore_errors=True) + + print("Lab 05 OK: the document written in the first run survived a full server restart.") + + +if __name__ == "__main__": + main() diff --git a/labs/README.md b/labs/README.md new file mode 100644 index 0000000..78f2b3c --- /dev/null +++ b/labs/README.md @@ -0,0 +1,19 @@ +# ravendb-embedded: labs + +Runnable, self-checking guides for getting a RavenDB server, from most convenient to most +portable. Each lab ships a script next to it, so you can run the exact code the guide shows. + +| Lab | Covers | Needs system .NET? | +|-----|--------|--------------------| +| [01](01-embedded-zero-config.md) | Embedded, zero-config (the default) | Yes (.NET 10 for 7.2.x) | +| [02](02-embedded-external-server.md) | External self-contained server you provide | No | +| [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 | + +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. + +Want the driver to attach to a server you run yourself (Docker, testcontainers, shared CI) instead +of running one? That is a test-driver feature and has its own lab in the +[`ravendb-python-testdriver`](https://github.com/ravendb/ravendb-python-testdriver) package. diff --git a/ravendb_embedded/__init__.py b/ravendb_embedded/__init__.py index f211727..faac0e1 100644 --- a/ravendb_embedded/__init__.py +++ b/ravendb_embedded/__init__.py @@ -1,5 +1,6 @@ from ravendb_embedded.embedded_server import EmbeddedServer from ravendb_embedded.options import DatabaseOptions, ServerOptions, SecurityOptions +from ravendb_embedded.on_demand import ensure_server from ravendb_embedded.provide import ( CopyServerFromNugetProvider, CopyServerProvider, diff --git a/ravendb_embedded/on_demand.py b/ravendb_embedded/on_demand.py new file mode 100644 index 0000000..0b89f19 --- /dev/null +++ b/ravendb_embedded/on_demand.py @@ -0,0 +1,109 @@ +import os +import platform +import shutil +import tarfile +import tempfile +import urllib.request +import zipfile +from pathlib import Path + +_DOWNLOAD_BASE = "https://hibernatingrhinos.com/downloads" +_DOWNLOAD_TIMEOUT_SECONDS = 30 + + +def _default_version_line() -> str: + # Match the installed package's RavenDB line (e.g. 7.2.5 -> "7.2"); fall back if unknown. + try: + from importlib.metadata import version + + return ".".join(version("ravendb-embedded").split(".")[:2]) + except Exception: + return "7.2" + + +def _platform_download() -> tuple: + machine = platform.machine().lower() + arch = "arm64" if machine in ("arm64", "aarch64") else "x64" + system = platform.system() + if system == "Windows": + return f"RavenDB for Windows {arch}", "zip" + if system == "Darwin": + return f"RavenDB for OSX {arch}", "tar.bz2" + return f"RavenDB for Linux {arch}", "tar.bz2" + + +def _extract_safely(archive: Path, dest: Path, extension: str) -> None: + # Guard against archive path-traversal (zip/tar slip): every member must resolve inside dest, + # and tar links are refused. (The stdlib default extraction is unfiltered before Python 3.12.) + dest = dest.resolve() + + def _inside(name: str) -> bool: + return (dest / name).resolve() == dest or dest in (dest / name).resolve().parents + + if extension == "zip": + with zipfile.ZipFile(archive) as bundle: + for name in bundle.namelist(): + if not _inside(name): + raise RuntimeError(f"Refusing to extract unsafe archive path: {name}") + bundle.extractall(dest) + else: + with tarfile.open(archive) as bundle: + for member in bundle.getmembers(): + if member.issym() or member.islnk(): + raise RuntimeError(f"Refusing to extract a link member from archive: {member.name}") + if not _inside(member.name): + raise RuntimeError(f"Refusing to extract unsafe archive path: {member.name}") + bundle.extractall(dest) + + +def ensure_server(version: str = None, cache_root: str = None) -> str: + """Return a local self-contained Server directory, downloading and caching on first use. + + The download is a self-contained build (bundles its own .NET), so it runs with no system + .NET. Pulling `latest` for the version line is intentional: a self-contained build never has + to match anything on the host. A completed cache entry (keyed on version + platform) is reused + and never re-downloaded. Download and extraction happen in a private temp directory that is + moved into place only once complete, so an interrupted or concurrent first run never leaves a + half-populated cache. + """ + version = version or _default_version_line() + root = Path(cache_root) if cache_root else Path.home() / ".cache" / "ravendb-embedded" + label, extension = _platform_download() + line_dir = root / version + target = line_dir / label.replace(" ", "_") + + cached = next(target.rglob("Raven.Server.dll"), None) if target.is_dir() else None + if cached: + return str(cached.parent) + + line_dir.mkdir(parents=True, exist_ok=True) + url = f"{_DOWNLOAD_BASE}/{label.replace(' ', '%20')}/latest?version={version}" + work = Path(tempfile.mkdtemp(prefix="download-", dir=line_dir)) + try: + archive = work / f"ravendb.{extension}" + try: + with urllib.request.urlopen(url, timeout=_DOWNLOAD_TIMEOUT_SECONDS) as response, open(archive, "wb") as out: + shutil.copyfileobj(response, out) + except OSError as error: + raise RuntimeError(f"Failed to download a self-contained RavenDB server from {url}: {error}") from error + + extracted = work / "server" + extracted.mkdir() + _extract_safely(archive, extracted, extension) + if not next(extracted.rglob("Raven.Server.dll"), None): + raise RuntimeError(f"Downloaded archive did not contain a RavenDB server: {url}") + + try: + os.replace(extracted, target) # atomic on the same filesystem + except OSError: + # A concurrent first run may have populated the cache first; prefer a complete entry. + if not next(target.rglob("Raven.Server.dll"), None): + shutil.rmtree(target, ignore_errors=True) + os.replace(extracted, target) + finally: + shutil.rmtree(work, ignore_errors=True) + + server = next(target.rglob("Raven.Server.dll"), None) + if not server: + raise RuntimeError(f"Server binaries not found under {target}") + return str(server.parent) diff --git a/ravendb_embedded/options.py b/ravendb_embedded/options.py index d483837..9cf4d5b 100644 --- a/ravendb_embedded/options.py +++ b/ravendb_embedded/options.py @@ -14,6 +14,7 @@ ExternalServerProvider, CopyServerFromNugetProvider, ) +from ravendb_embedded.on_demand import ensure_server class DatabaseOptions: @@ -96,3 +97,12 @@ def secured( 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 + # collide with the bundled server sitting at the default target location. + if os.path.isdir(server_location): + self.target_server_location = server_location + self.clear_target_server_location = False # never wipe the user's own server directory + + def with_auto_downloaded_server(self, version: str = None, cache_root: str = None) -> None: + # Download (once) and cache a self-contained server, then run it with no system .NET. + self.with_external_server(ensure_server(version, cache_root)) diff --git a/ravendb_embedded/provide.py b/ravendb_embedded/provide.py index 373cdee..ecffbb3 100644 --- a/ravendb_embedded/provide.py +++ b/ravendb_embedded/provide.py @@ -1,3 +1,4 @@ +import json import os import pkgutil import shutil @@ -19,6 +20,8 @@ def __init__(self, server_files: str): self.server_files = server_files def provide(self, target_directory: str) -> None: + if os.path.abspath(self.server_files) == os.path.abspath(target_directory): + return # already in place: run the server where it is, nothing to copy try: shutil.copytree(self.server_files, target_directory) except FileExistsError: @@ -47,7 +50,6 @@ def __init__(self, source_location: str): self.source_location = source_location def provide(self, target_directory): - # Ensure the target directory exists os.makedirs(target_directory, exist_ok=True) with open(self.source_location, "rb") as zip_file: self.unzip(zip_file, target_directory) @@ -62,15 +64,12 @@ class ExtractFromPkgResourceServerProvider(ProvideRavenDBServer): def provide(self, target_directory): resource_name = "ravendb_server.zip" - # Get binary data from the resource resource_data = pkgutil.get_data(self.__class__.__module__, resource_name) if resource_data is None: raise RuntimeError(f"Unable to find resource: {resource_name}") - # Create a bytes buffer from the binary data with BytesIO(resource_data) as bytes_buffer: - # Call the unzip method to extract contents to the target directory ExtractFromZipServerProvider.unzip(bytes_buffer.read(), target_directory) @@ -87,30 +86,50 @@ def __init__(self, server_location: str): if not os.path.exists(file_server_location): raise ValueError(f"Server location doesn't exist: {server_location}") - # Check if target is a file - assuming it is a zip file if os.path.isfile(file_server_location): self.inner_provider = ExtractFromZipServerProvider(server_location) return - # Alternatively, it might be a directory - look for Raven.Server.exe inside - if os.path.isdir(file_server_location) and os.path.exists( - os.path.join(file_server_location, self.SERVER_DLL_FILENAME) - ): - self.inner_provider = CopyServerProvider(server_location) - return + # Check self-contained first: it also ships Raven.Server.dll, so a .dll-first check + # would misroute it to `dotnet` and force a system .NET install. + if os.path.isdir(file_server_location): + if self._is_self_contained(file_server_location): + self.is_single_file_app = True + self.inner_provider = CopyServerProvider(server_location) + return - # Also look for Single File App file - Raven.Server - if os.path.isdir(file_server_location) and os.path.exists( - os.path.join(file_server_location, self.SERVER_SFA_FILENAME) - ): - self.is_single_file_app = True - self.inner_provider = CopyServerProvider(server_location) - return + if os.path.exists(os.path.join(file_server_location, self.SERVER_DLL_FILENAME)): + self.inner_provider = CopyServerProvider(server_location) + return raise ValueError( f"Unable to find RavenDB server (expected directory with {self.SERVER_DLL_FILENAME}) or zip file. " f"Used directory = {server_location}" ) + @staticmethod + def _is_self_contained(directory: str) -> bool: + # Self-contained marker: `includedFrameworks` in the runtime config, or an apphost + # present with no managed .dll. + runtime_config = os.path.join(directory, "Raven.Server.runtimeconfig.json") + if os.path.isfile(runtime_config): + try: + with open(runtime_config, encoding="utf-8") as config_file: + runtime_options = json.load(config_file).get("runtimeOptions", {}) + if runtime_options.get("includedFrameworks"): + return True + except (OSError, ValueError): + pass + + has_managed_dll = os.path.exists(os.path.join(directory, ExternalServerProvider.SERVER_DLL_FILENAME)) + has_apphost = any( + os.path.exists(os.path.join(directory, name)) + for name in ( + ExternalServerProvider.SERVER_SFA_FILENAME, + f"{ExternalServerProvider.SERVER_SFA_FILENAME}.exe", + ) + ) + return has_apphost and not has_managed_dll + def provide(self, target_directory: str) -> None: self.inner_provider.provide(target_directory) diff --git a/ravendb_embedded/raven_server_runner.py b/ravendb_embedded/raven_server_runner.py index bcd6e4c..30923df 100644 --- a/ravendb_embedded/raven_server_runner.py +++ b/ravendb_embedded/raven_server_runner.py @@ -6,19 +6,12 @@ from cryptography.hazmat.primitives import hashes from ravendb.exceptions.raven_exceptions import RavenException -from ravendb_embedded.provide import ExternalServerProvider from ravendb_embedded.options import ServerOptions from ravendb_embedded.runtime_framework_version_matcher import ( RuntimeFrameworkVersionMatcher, ) -class CommandLineArgumentEscaper: - @staticmethod - def escape_single_arg(arg: str) -> str: - return arg # lol - - class RavenServerRunner: @staticmethod def run(options: ServerOptions) -> subprocess.Popen: @@ -31,8 +24,12 @@ def run(options: ServerOptions) -> subprocess.Popen: if not options.logs_path.strip(): raise ValueError("logs_path cannot be None or whitespace") - is_sfa = isinstance(options.provider, ExternalServerProvider) and options.provider.is_single_file_app - file_name = "Raven.Server" if is_sfa else "Raven.Server.dll" + is_sfa = getattr(options.provider, "is_single_file_app", False) + if is_sfa: + # Self-contained / single-file build: run the native apphost, no `dotnet`. + file_name = "Raven.Server.exe" if os.name == "nt" else "Raven.Server" + else: + file_name = "Raven.Server.dll" server_paths = [ f"{file_name}", @@ -54,41 +51,30 @@ def run(options: ServerOptions) -> subprocess.Popen: if not options.dot_net_path.strip(): raise ValueError("dot_net_path cannot be None or whitespace") + # 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"--License.Eula.Accepted={'true' if options.accept_eula else 'false'}", "--Setup.Mode=None", - f"--DataDir={CommandLineArgumentEscaper.escape_single_arg(options.data_directory)}", - f"--Logs.Path={CommandLineArgumentEscaper.escape_single_arg(options.logs_path)}", + f"--DataDir={options.data_directory}", + f"--Logs.Path={options.logs_path}", ] if options.security: options.server_url = options.server_url or "https://127.0.0.1:0" if options.security.server_pfx_certificate_path: - command_line_args.extend( - [ - f"--Security.Certificate.Path=" - f"{CommandLineArgumentEscaper.escape_single_arg(options.security.server_pfx_certificate_path)}" - ] - ) + command_line_args.append(f"--Security.Certificate.Path={options.security.server_pfx_certificate_path}") if options.security.server_pfx_certificate_password: - command_line_args.extend( - [ - "--Security.Certificate.Password=" - + CommandLineArgumentEscaper.escape_single_arg( - options.security.server_pfx_certificate_password - ) - ] + command_line_args.append( + f"--Security.Certificate.Password={options.security.server_pfx_certificate_password}" ) elif options.security.certificate_exec: command_line_args.extend( [ - f"--Security.Certificate.Exec=" - f"{CommandLineArgumentEscaper.escape_single_arg(options.security.certificate_exec)}", - f"--Security.Certificate.Exec.Arguments=" - f"{CommandLineArgumentEscaper.escape_single_arg(options.security.certificate_arguments)}", + f"--Security.Certificate.Exec={options.security.certificate_exec}", + f"--Security.Certificate.Exec.Arguments={options.security.certificate_arguments}", ] ) if options.security.client_pem_certificate_path: @@ -98,12 +84,7 @@ def run(options: ServerOptions) -> subprocess.Popen: cert = x509.load_pem_x509_certificate(cert_data, default_backend()) thumbprint = cert.fingerprint(hashes.SHA256()).hex() - command_line_args.extend( - [ - f"--Security.WellKnownCertificates.Admin=" - f"{CommandLineArgumentEscaper.escape_single_arg(thumbprint)}" - ] - ) + command_line_args.append(f"--Security.WellKnownCertificates.Admin={thumbprint}") else: options.server_url = options.server_url or "http://127.0.0.1:0" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 6f4b66e..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -ravendb~=7.2.3 -cryptography>=42.0.0 \ No newline at end of file diff --git a/scripts/check_dotnet_requirement.py b/scripts/check_dotnet_requirement.py new file mode 100644 index 0000000..c6dfb5a --- /dev/null +++ b/scripts/check_dotnet_requirement.py @@ -0,0 +1,58 @@ +"""Assert the installed .NET runtime matches what the bundled RavenDB server requires. + +Reads the bundled server's runtimeconfig and checks that a matching Microsoft.NETCore.App +major runtime is installed. This turns the otherwise hidden server-to-.NET coupling (which +silently jumped from .NET 8 to .NET 10 between 7.1 and 7.2) into an enforced invariant, so CI +fails loudly instead of passing by luck on a preinstalled runtime. + +Run after fetching the bundled server (`python setup.py sdist`): + python scripts/check_dotnet_requirement.py +""" + +import json +import subprocess +import sys +from pathlib import Path + +RUNTIME_CONFIG = Path( + "ravendb_embedded/target/nuget/contentFiles/any/any/RavenDBServer/Raven.Server.runtimeconfig.json" +) + + +def required_dotnet_major(runtime_config: Path) -> str: + options = json.loads(runtime_config.read_text(encoding="utf-8"))["runtimeOptions"] + frameworks = list(options.get("frameworks") or options.get("includedFrameworks") or []) + if isinstance(options.get("framework"), dict): + frameworks.insert(0, options["framework"]) + for framework in frameworks: + if framework.get("name") == "Microsoft.NETCore.App" and framework.get("version"): + return framework["version"].split(".")[0] + return options.get("tfm", "").removeprefix("net").split(".")[0] # e.g. "net10.0" -> "10" + + +def main() -> int: + if not RUNTIME_CONFIG.exists(): + print(f"Server runtimeconfig not found at {RUNTIME_CONFIG}; fetch the server first (python setup.py sdist).") + return 1 + + major = required_dotnet_major(RUNTIME_CONFIG) + if not major: + print(f"Could not determine the required .NET version from {RUNTIME_CONFIG}.") + return 1 + + try: + installed = subprocess.run(["dotnet", "--list-runtimes"], capture_output=True, text=True).stdout + except FileNotFoundError: + print(f"No 'dotnet' found on PATH; the bundled server requires .NET major {major}.") + return 1 + + matched = f"Microsoft.NETCore.App {major}." in installed + print(f"Bundled server requires .NET major {major}; matching runtime installed: {matched}") + if not matched: + print(f"Installed .NET runtimes do not include major {major}, which the bundled server requires.") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/setup.py b/setup.py index a7ac5e9..0bd42ed 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ import urllib.request from setuptools.command.sdist import sdist -RAVENDB_VERSION = "7.2.3" +RAVENDB_VERSION = "7.2.5" ZIP_FILE_NAME = "server.zip" RAVENDB_DOWNLOAD_URL = f"https://www.nuget.org/api/v2/package/RavenDB.Embedded/{RAVENDB_VERSION}" @@ -38,14 +38,15 @@ def run(self): setup( - python_requires=">=3.9", + python_requires=">=3.10", cmdclass={"sdist": CustomSDist}, name="ravendb-embedded", packages=["ravendb_embedded"], package_dir={"ravendb_embedded": "ravendb_embedded"}, include_package_data=True, - long_description=open("README.rst").read(), - version="7.2.3", + long_description=open("README.md").read(), + long_description_content_type="text/markdown", + version="7.2.5", description="RavenDB Embedded library to run RavenDB in an embedded way", author="RavenDB", author_email="support@ravendb.net", @@ -53,7 +54,7 @@ def run(self): license="Custom EULA", keywords="ravendb embedded database nosql doc db", install_requires=[ - "ravendb~=7.2.3", + "ravendb==7.2.3.post1", "cryptography>=42.0.0", ], license_files="LICENSE", diff --git a/tests/__init__.py b/tests/__init__.py index 81176b6..a9e5908 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,16 +1,4 @@ -import os - - class Person: def __init__(self, Id: str = None, name: str = None): self.Id = Id self.name = name - - -def pin_framework_version(server_options): - # CI's .NET-version matrix sets RAVENDB_TEST_FRAMEWORK_VERSION to force the server onto a - # specific runtime (e.g. "10.0.x"); a no-op locally when it's unset. - framework_version = os.environ.get("RAVENDB_TEST_FRAMEWORK_VERSION") - if framework_version: - server_options.framework_version = framework_version - return server_options diff --git a/tests/test_basic.py b/tests/test_basic.py index b3c1f86..526eb08 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -4,7 +4,7 @@ from unittest import TestCase from ravendb_embedded import EmbeddedServer, ServerOptions, CopyServerFromNugetProvider, DatabaseOptions -from tests import Person, pin_framework_version +from tests import Person class BasicTest(TestCase): @@ -17,7 +17,6 @@ def test_embedded(self): server_options.logs_path = str(Path(temp_dir, "Logs")) server_options.provider = CopyServerFromNugetProvider() server_options.command_line_args = ["--Features.Availability=Experimental"] - pin_framework_version(server_options) embedded.start_server(server_options) database_options = DatabaseOptions.from_database_name("Test") @@ -38,7 +37,6 @@ def test_embedded(self): server_options = ServerOptions() server_options.data_directory = str(Path(temp_dir, "RavenDB")) server_options.provider = CopyServerFromNugetProvider() - pin_framework_version(server_options) embedded.start_server(server_options) with embedded.get_document_store("Test") as store: diff --git a/tests/test_custom_provider.py b/tests/test_custom_provider.py index 9880a5d..bae95a7 100644 --- a/tests/test_custom_provider.py +++ b/tests/test_custom_provider.py @@ -6,7 +6,7 @@ from ravendb_embedded.embedded_server import EmbeddedServer from ravendb_embedded.options import ServerOptions, DatabaseOptions from ravendb_embedded.provide import CopyServerFromNugetProvider -from tests import Person, pin_framework_version +from tests import Person class TestCustomProvider(TestCase): @@ -16,7 +16,6 @@ def configure_server_options(temp_dir: str, server_options: ServerOptions) -> Se server_options.data_directory = str(Path(temp_dir, "RavenDB")) server_options.logs_path = str(Path(temp_dir, "Logs")) server_options.command_line_args = ["--Features.Availability=Experimental"] - pin_framework_version(server_options) return server_options def test_can_use_zip_as_external_server_source(self): diff --git a/tests/test_on_demand.py b/tests/test_on_demand.py new file mode 100644 index 0000000..b3d0f23 --- /dev/null +++ b/tests/test_on_demand.py @@ -0,0 +1,52 @@ +import io +import tarfile +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest import mock + +from ravendb_embedded.on_demand import _extract_safely, _platform_download, ensure_server + + +class TestOnDemand(unittest.TestCase): + def test_cache_hit_skips_download(self): + # A populated cache must be reused without any network call ("by design"). + with tempfile.TemporaryDirectory() as cache_root: + label, _ = _platform_download() + server_dir = Path(cache_root, "7.2", label.replace(" ", "_"), "Server") + server_dir.mkdir(parents=True) + (server_dir / "Raven.Server.dll").write_bytes(b"stub") + + with mock.patch("urllib.request.urlopen", side_effect=AssertionError("cache hit must not download")): + resolved = ensure_server(version="7.2", cache_root=cache_root) + + self.assertEqual(str(server_dir), resolved) + + def test_platform_download_targets_a_known_os(self): + label, extension = _platform_download() + self.assertTrue(label.startswith("RavenDB for ")) + self.assertIn(extension, ("zip", "tar.bz2")) + + def test_extract_rejects_path_traversal(self): + # A tampered archive must not be able to write outside the destination (zip/tar slip). + with tempfile.TemporaryDirectory() as work: + dest = Path(work, "out") + dest.mkdir() + + bad_tar = Path(work, "bad.tar.bz2") + with tarfile.open(bad_tar, "w:bz2") as tar: + payload = b"x" + info = tarfile.TarInfo("../escaped.txt") + info.size = len(payload) + tar.addfile(info, io.BytesIO(payload)) + with self.assertRaises(RuntimeError): + _extract_safely(bad_tar, dest, "tar.bz2") + + bad_zip = Path(work, "bad.zip") + with zipfile.ZipFile(bad_zip, "w") as archive: + archive.writestr("../escaped.txt", "x") + with self.assertRaises(RuntimeError): + _extract_safely(bad_zip, dest, "zip") + + self.assertFalse((Path(work) / "escaped.txt").exists()) diff --git a/tests/test_secured_basic.py b/tests/test_secured_basic.py index 72feebc..cf8bcf7 100644 --- a/tests/test_secured_basic.py +++ b/tests/test_secured_basic.py @@ -6,7 +6,7 @@ from ravendb_embedded.embedded_server import EmbeddedServer from ravendb_embedded.options import ServerOptions, DatabaseOptions from ravendb_embedded.provide import CopyServerFromNugetProvider -from tests import Person, pin_framework_version +from tests import Person from tests.certificates import generate_self_signed_certificates @@ -21,7 +21,6 @@ def test_secured_embedded(self): server_options.data_directory = str(Path(temp_dir, "RavenDB")) server_options.logs_path = str(Path(temp_dir, "Logs")) server_options.provider = CopyServerFromNugetProvider() - pin_framework_version(server_options) embedded.start_server(server_options) database_options = DatabaseOptions.from_database_name("Test")