diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cbd548f..323cd08 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -91,3 +91,22 @@ jobs: env: RAVENDB_TEST_SERVER_URL: http://localhost:8080 run: python labs/01_attach_to_server.py + + self-contained: + 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: Run Lab 04 (self-contained, no system .NET) + run: python labs/04_embedded_no_dotnet.py diff --git a/README.md b/README.md index d9044f7..f8e4e41 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ Python 3.10+ is required. ## Providing a server: pick one -The driver needs a RavenDB server to talk to. There are two ways to give it one; choose based on -whether you want .NET on the test machine. +The driver needs a RavenDB server to talk to. Choose how it should get one based on your +environment. ### 1. Embedded server (default, needs .NET) @@ -32,7 +32,29 @@ installed: Check with `dotnet --list-runtimes`. The requirement tracks the embedded server and can change on a minor upgrade, so re-check it when you bump versions. -### 2. Attach to a server you run yourself (no .NET) +### 2. Self-contained embedded server (no system .NET) + +Let the driver download and cache a self-contained RavenDB build. It manages the server for you +without calling `dotnet`: + +```python +from ravendb_embedded import ServerOptions +from ravendb_test_driver import RavenTestDriver + +options = ServerOptions() +options.with_auto_downloaded_server() +RavenTestDriver.configure_server(options) +``` + +The embedded package detects the host operating system and architecture, so the same test +configuration is portable across supported Windows, Linux, and macOS machines. + +Call `configure_server()` before the first `get_document_store()`. The first run downloads +100 MB+; later runs reuse the cache. Self-contained mode needs no system .NET, but normal RavenDB +OS dependencies still apply; minimal Linux images may need their distribution's ICU package. See +[`labs/04-embedded-no-dotnet.md`](labs/04-embedded-no-dotnet.md). + +### 3. Attach to a server you run yourself (no .NET) If you would rather not put .NET on the test machine (containerized CI, locked-down hosts), run RavenDB yourself (Docker, testcontainers, a shared CI service) and point the driver at its URL. @@ -42,13 +64,23 @@ The driver skips the embedded boot entirely and still creates an isolated databa from ravendb_test_driver import RavenTestDriver RavenTestDriver.configure_external_server("http://localhost:8080") -# or set RAVENDB_TEST_SERVER_URL in the environment (handy for CI) +# or set RAVENDB_TEST_SERVER_URL in the environment ``` -Call it once, before the first `get_document_store()`. A runnable Docker / testcontainers guide -is in [`labs/01-attach-to-server.md`](labs/01-attach-to-server.md). For the embedded and -self-contained server options, see the -[`ravendb-python-embedded`](https://github.com/ravendb/ravendb-python-embedded) repository. +For HTTPS with client-certificate authentication: + +```python +RavenTestDriver.configure_external_server( + "https://my-ravendb", + certificate_pem_path="client.pem", + trust_store_path="ca.crt", # needed when the server CA is not already trusted +) +``` + +The equivalent environment variables are `RAVENDB_TEST_SERVER_URL`, +`RAVENDB_TEST_SERVER_CERT`, and `RAVENDB_TEST_SERVER_CA`. Call the configuration method once, +before the first `get_document_store()`. A runnable Docker / testcontainers guide is in +[`labs/01-attach-to-server.md`](labs/01-attach-to-server.md). ## Usage @@ -87,6 +119,8 @@ Runnable example: [`labs/03-seeding-indexes.md`](labs/03-seeding-indexes.md). ## Links +The runnable lab scripts live in this repository and are not installed into `site-packages`. + - PyPI: https://pypi.org/project/ravendb-test-driver/ - GitHub: https://github.com/ravendb/ravendb-python-testdriver - Server and self-contained options: https://github.com/ravendb/ravendb-python-embedded diff --git a/labs/01-attach-to-server.md b/labs/01-attach-to-server.md index 64a7953..7ce331d 100644 --- a/labs/01-attach-to-server.md +++ b/labs/01-attach-to-server.md @@ -20,8 +20,9 @@ RavenTestDriver.configure_external_server("http://localhost:8080") ``` For a secured (https) server, pass the client certificate: `configure_external_server(url, -certificate_pem_path=...)` (or set `RAVENDB_TEST_SERVER_CERT`); attaching to https without one -fails fast with a clear message. +certificate_pem_path=..., trust_store_path=...)`. The trust store is needed when the server CA is +not already trusted. The environment equivalents are `RAVENDB_TEST_SERVER_CERT` and +`RAVENDB_TEST_SERVER_CA`; attaching to HTTPS without a client certificate fails fast. Then use the driver exactly as with the embedded server: @@ -36,12 +37,13 @@ The complete runnable example is [`01_attach_to_server.py`](01_attach_to_server. ## Run a server with Docker ```bash -docker run -d -p 8080:8080 \ +docker run --rm -d --name ravendb-test-driver-lab -p 8080:8080 \ -e RAVEN_Setup_Mode=None -e RAVEN_License_Eula_Accepted=true \ -e RAVEN_Security_UnsecuredAccessAllowed=PublicNetwork -e RAVEN_ServerUrl=http://0.0.0.0:8080 \ ravendb/ravendb:7.2-ubuntu-latest RAVENDB_TEST_SERVER_URL=http://localhost:8080 python labs/01_attach_to_server.py +docker stop ravendb-test-driver-lab ``` ## Run a server with testcontainers-python @@ -68,11 +70,7 @@ from ravendb_test_driver import RavenTestDriver RavenTestDriver.configure_external_server(url) ``` -## In CI - -This repo's own CI uses a GitHub Actions service container (see `.github/workflows/tests.yml`, -the `attach` job): it runs `ravendb/ravendb:7.2-ubuntu-latest`, waits for it, then runs the -attach test with `RAVENDB_TEST_SERVER_URL` set and no .NET installed. +Stop the container in your fixture or test cleanup. ## Cleanup on a shared server @@ -84,5 +82,4 @@ and prune leftover `test_*` databases between runs. ## Takeaway No embedded server and no .NET, at the cost of running RavenDB yourself. If you would rather -have the driver run the server for you (with or without .NET), see the embedded options in the -`ravendb-python-embedded` repository. +have the driver download and manage a self-contained server, use Lab 04. diff --git a/labs/02-embedded-per-test.md b/labs/02-embedded-per-test.md index 3a7f6ef..4a0c3d4 100644 --- a/labs/02-embedded-per-test.md +++ b/labs/02-embedded-per-test.md @@ -35,4 +35,5 @@ to the other. That isolation is what keeps tests independent. ## Takeaway No server to manage in your tests: the driver runs one and gives each test its own database. To -run without .NET, attach to a server you start yourself (Lab 01). +run without .NET, attach to a server you start yourself (Lab 01), or let the driver automatically +download, cache, and manage a self-contained embedded server (Lab 04). diff --git a/labs/04-embedded-no-dotnet.md b/labs/04-embedded-no-dotnet.md new file mode 100644 index 0000000..4cd227f --- /dev/null +++ b/labs/04-embedded-no-dotnet.md @@ -0,0 +1,44 @@ +# Lab 04: Self-contained embedded server without system .NET + +**For:** test suites that want the driver to manage RavenDB without installing .NET and without +running a separate server. `ravendb-embedded` downloads and caches a self-contained RavenDB build, +then the test driver uses it while keeping each test database isolated. + +The embedded package detects the host operating system and architecture at runtime. This keeps the +same test suite portable across supported Windows, Linux, and macOS machines without per-platform +server paths or a matching system .NET installation. + +## Run it + +Run from a checkout of this repository: + +```bash +pip install ravendb-test-driver +python labs/04_embedded_no_dotnet.py +``` + +The complete example is [`04_embedded_no_dotnet.py`](04_embedded_no_dotnet.py). The core is: + +```python +from ravendb_embedded import ServerOptions +from ravendb_test_driver import RavenTestDriver + +options = ServerOptions() +options.with_auto_downloaded_server() +RavenTestDriver.configure_server(options) + +with RavenTestDriver() as driver: + with driver.get_document_store() as store: + ... # isolated database on a self-contained server +``` + +The first run downloads a self-contained build (100 MB+) to +`~/.cache/ravendb-embedded`; later runs reuse that cache. Pass `cache_root` to +`with_auto_downloaded_server()` when your build system restores a different cache directory. +Normal RavenDB operating-system dependencies still apply; minimal Linux images may need their +distribution's ICU package. + +## Takeaway + +This mode keeps the zero-server-management experience of embedded tests while making the same test +configuration portable across supported platforms without requiring a system .NET runtime. diff --git a/labs/04_embedded_no_dotnet.py b/labs/04_embedded_no_dotnet.py new file mode 100644 index 0000000..139716a --- /dev/null +++ b/labs/04_embedded_no_dotnet.py @@ -0,0 +1,41 @@ +"""Lab 04: Self-contained embedded server without system .NET. + +For: test suites that want the driver to manage RavenDB without installing .NET or running a +separate server. The first run downloads and caches a self-contained RavenDB build. + +Run: python labs/04_embedded_no_dotnet.py +""" + +import atexit +import shutil +import tempfile +from pathlib import Path + +from ravendb_embedded import ServerOptions +from ravendb_test_driver import RavenTestDriver + + +def main() -> None: + work = tempfile.mkdtemp() + atexit.register(lambda: shutil.rmtree(work, ignore_errors=True)) + + options = ServerOptions() + options.dot_net_path = "__no_dotnet__" + options.with_auto_downloaded_server() + options.data_directory = str(Path(work, "data")) + options.logs_path = str(Path(work, "logs")) + RavenTestDriver.configure_server(options) + + with RavenTestDriver() as driver: + with driver.get_document_store() as store: + with store.open_session() as session: + session.store({"name": "no-dotnet"}, "people/1") + session.save_changes() + with store.open_session() as session: + assert session.load("people/1", dict)["name"] == "no-dotnet" + + print("Lab 04 OK: self-contained embedded server ran without system .NET.") + + +if __name__ == "__main__": + main() diff --git a/labs/README.md b/labs/README.md index f6eada6..706bc3f 100644 --- a/labs/README.md +++ b/labs/README.md @@ -3,12 +3,15 @@ Runnable, self-checking guides for using the test driver. Each lab ships a script next to it, so you can run the exact code the guide shows. +The scripts are part of this repository and are not installed into `site-packages`. Clone or +download the repository first, then run them from its root. + | Lab | Covers | Needs system .NET? | |-----|--------|--------------------| | [01](01-attach-to-server.md) | Attach to a server you run yourself (Docker, testcontainers, shared CI) | No | | [02](02-embedded-per-test.md) | Embedded server, one isolated database per test (the default) | Yes | | [03](03-seeding-indexes.md) | Seed data and query an index (`setup_database`, `wait_for_indexing`) | Yes | +| [04](04-embedded-no-dotnet.md) | Self-contained embedded server, downloaded and cached automatically | No | -Looking for how to run RavenDB itself (embedded, or self-contained without .NET)? That belongs to -the [`ravendb-python-embedded`](https://github.com/ravendb/ravendb-python-embedded) package and -has its own labs there. +For lower-level server configuration, see the +[`ravendb-python-embedded`](https://github.com/ravendb/ravendb-python-embedded) labs. diff --git a/ravendb_test_driver/raven_test_driver.py b/ravendb_test_driver/raven_test_driver.py index dbe8cd2..e4c87d1 100644 --- a/ravendb_test_driver/raven_test_driver.py +++ b/ravendb_test_driver/raven_test_driver.py @@ -37,6 +37,7 @@ class RavenTestDriver: _EMPTY_SETTINGS_FILE_NAME: Optional[str] = None _EXTERNAL_SERVER_URL: Optional[str] = None _EXTERNAL_SERVER_CERT: Optional[str] = None + _EXTERNAL_SERVER_TRUST_STORE: Optional[str] = None def __init__(self) -> None: self.disposed = False @@ -68,11 +69,16 @@ def configure_server(options: ServerOptions) -> None: RavenTestDriver._GLOBAL_SERVER_OPTIONS = options @staticmethod - def configure_external_server(url: str, certificate_pem_path: str = None) -> None: + def configure_external_server( + url: str, + certificate_pem_path: str = None, + trust_store_path: str = None, + ) -> None: """Attach to a server you run yourself (no embedded boot, no .NET); still one database per test. - For a secured (https) server, pass the client certificate `.pem`. Equivalent to setting - RAVENDB_TEST_SERVER_URL (and RAVENDB_TEST_SERVER_CERT). Call before the first get_document_store. + For HTTPS, pass the client certificate and, when needed, the CA trust store. The equivalent + environment variables are RAVENDB_TEST_SERVER_URL, RAVENDB_TEST_SERVER_CERT, and + RAVENDB_TEST_SERVER_CA. Call before the first get_document_store. """ if RavenTestDriver._TEST_SERVER_STORE.is_value_created: raise RuntimeError( @@ -81,6 +87,7 @@ def configure_external_server(url: str, certificate_pem_path: str = None) -> Non ) RavenTestDriver._EXTERNAL_SERVER_URL = url RavenTestDriver._EXTERNAL_SERVER_CERT = certificate_pem_path + RavenTestDriver._EXTERNAL_SERVER_TRUST_STORE = trust_store_path def get_document_store( self, @@ -97,6 +104,10 @@ def get_document_store( document_store.maintenance.server.send(create_database_operation) store = DocumentStore(document_store.urls, name) + if document_store.certificate_pem_path: + store.certificate_pem_path = document_store.certificate_pem_path + if document_store.trust_store_path: + store.trust_store_path = document_store.trust_store_path self.pre_initialize(store) store.initialize() @@ -252,6 +263,7 @@ def run_server(cls) -> DocumentStore: if external_url: # Attach to an existing server; do not boot the embedded one (no .NET needed). certificate = cls._EXTERNAL_SERVER_CERT or os.environ.get("RAVENDB_TEST_SERVER_CERT") + trust_store = cls._EXTERNAL_SERVER_TRUST_STORE or os.environ.get("RAVENDB_TEST_SERVER_CA") if external_url.lower().startswith("https") and not certificate: raise RavenException( f"Attaching to a secured server ({external_url}) needs a client certificate; pass " @@ -260,6 +272,8 @@ def run_server(cls) -> DocumentStore: store = DocumentStore(external_url, None) if certificate: store.certificate_pem_path = certificate + if trust_store: + store.trust_store_path = trust_store store.initialize() return store diff --git a/setup.py b/setup.py index e1629ef..10de8d0 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setup( name="ravendb-test-driver", packages=find_packages(exclude=["*.tests.*", "tests", "*.tests", "tests.*"]), - version="7.2.5", + version="7.2.5.post1", description="RavenDB package for writing integration tests against RavenDB server", long_description_content_type="text/markdown", long_description=open("README.md").read(), @@ -13,6 +13,6 @@ license="MIT", keywords=["ravendb", "nosql", "database", "test", "driver"], python_requires=">=3.10", - license_files="LICENSE", - install_requires=["ravendb-embedded==7.2.5", "ravendb==7.2.3.post1"], + license_files=["LICENSE"], + install_requires=["ravendb-embedded==7.2.5.post1", "ravendb==7.2.3.post1"], ) diff --git a/tests/test_a_secured_attach.py b/tests/test_a_secured_attach.py new file mode 100644 index 0000000..70ec0a8 --- /dev/null +++ b/tests/test_a_secured_attach.py @@ -0,0 +1,142 @@ +import datetime +import ipaddress +import os +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.hazmat.primitives.serialization import pkcs12 +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID +from ravendb import Lazy +from ravendb_embedded import EmbeddedServer, ServerOptions + +from ravendb_test_driver import RavenTestDriver + + +def _certificates(directory): + 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) + 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=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()) + ) + certificate_pem = certificate.public_bytes(serialization.Encoding.PEM) + key_pem = key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + ) + + server_pfx = Path(directory, "server.pfx") + client_pem = Path(directory, "client.pem") + ca_certificate = Path(directory, "ca.crt") + server_pfx.write_bytes( + pkcs12.serialize_key_and_certificates( + b"localhost", + key, + certificate, + None, + serialization.NoEncryption(), + ) + ) + client_pem.write_bytes(key_pem + certificate_pem) + ca_certificate.write_bytes(certificate_pem) + return str(server_pfx), str(client_pem), str(ca_certificate) + + +class TestSecuredAttach(TestCase): + _ENV_NAMES = ("RAVENDB_TEST_SERVER_URL", "RAVENDB_TEST_SERVER_CERT", "RAVENDB_TEST_SERVER_CA") + + @staticmethod + def _reset_driver(): + lazy = RavenTestDriver._TEST_SERVER_STORE + if lazy.is_value_created: + lazy.value.close() + RavenTestDriver._EXTERNAL_SERVER_URL = None + RavenTestDriver._EXTERNAL_SERVER_CERT = None + RavenTestDriver._EXTERNAL_SERVER_TRUST_STORE = None + RavenTestDriver._TEST_SERVER_STORE = Lazy(lambda: RavenTestDriver.run_server()) + RavenTestDriver._INDEX = 0 + for name in TestSecuredAttach._ENV_NAMES: + os.environ.pop(name, None) + + def _write_and_read(self, database): + with RavenTestDriver() as driver: + with driver.get_document_store(database=database) as store: + with store.open_session() as session: + session.store({"name": database}, "people/1") + session.save_changes() + with store.open_session() as session: + self.assertEqual(database, session.load("people/1", dict)["name"]) + + def test_api_and_environment_credentials_reach_database_store(self): + original_environment = {name: os.environ.get(name) for name in self._ENV_NAMES} + with tempfile.TemporaryDirectory() as directory: + server_pfx, client_pem, ca_certificate = _certificates(directory) + options = ServerOptions() + options.secured( + server_pfx, + client_pem, + ca_certificate_path=ca_certificate, + ) + options.data_directory = str(Path(directory, "data")) + options.logs_path = str(Path(directory, "logs")) + + try: + with EmbeddedServer() as server: + server.start_server(options) + + RavenTestDriver.configure_external_server( + server.get_server_uri(), + certificate_pem_path=client_pem, + trust_store_path=ca_certificate, + ) + self._write_and_read("configured") + self._reset_driver() + + os.environ["RAVENDB_TEST_SERVER_URL"] = server.get_server_uri() + os.environ["RAVENDB_TEST_SERVER_CERT"] = client_pem + os.environ["RAVENDB_TEST_SERVER_CA"] = ca_certificate + self._write_and_read("environment") + finally: + self._reset_driver() + for name, value in original_environment.items(): + if value is not None: + os.environ[name] = value