diff --git a/README.md b/README.md index 7b15af8..5823e27 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,12 @@ qql connect --url http://localhost:6333 # Qdrant Cloud qql connect --url https://.qdrant.io --secret + +# Internal/self-signed certificate +qql connect --url https://:6333 --secret --ca-cert /path/to/ca.pem + +# Disable TLS verification when you cannot provide a CA bundle +qql connect --url https://:6333 --secret --no-verify ``` Then type `qql` to open the interactive shell. diff --git a/docs/getting-started.md b/docs/getting-started.md index bff4473..fa54570 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -77,6 +77,24 @@ qql connect --url http://localhost:6333 qql connect --url https://.qdrant.io --secret ``` +### HTTPS with an internal CA or self-signed certificate + +Prefer a custom CA bundle when your Qdrant endpoint uses an internal or +self-signed certificate: + +```bash +qql connect --url https://:6333 --secret --ca-cert /path/to/ca.pem +``` + +If you cannot provide a CA bundle, TLS verification can be disabled: + +```bash +qql connect --url https://:6333 --secret --no-verify +``` + +`--no-verify` should be limited to trusted internal environments because it +skips certificate validation. + On success you will see: ``` diff --git a/docs/programmatic.md b/docs/programmatic.md index d632a0d..007c9d6 100644 --- a/docs/programmatic.md +++ b/docs/programmatic.md @@ -70,6 +70,30 @@ with Connection("https://.qdrant.io", secret="") as print(result.data) ``` +### Internal or self-signed certificates + +Prefer a custom CA bundle when your Qdrant endpoint uses an internal or +self-signed certificate: + +```python +from qql import Connection + +with Connection( + "https://:6333", + secret="", + verify="/path/to/ca.pem", +) as conn: + result = conn.run_query("SHOW COLLECTIONS") +``` + +If a CA bundle is not available, pass `verify=False` to disable TLS +verification for trusted internal environments: + +```python +with Connection("https://:6333", verify=False) as conn: + ... +``` + ### Custom embedding model ```python @@ -155,6 +179,7 @@ with Connection("http://localhost:6333") as conn: | `url` | `str` | `"http://localhost:6333"` | Qdrant instance URL | | `secret` | `str \| None` | `None` | API key; `None` for unauthenticated | | `default_model` | `str \| None` | `None` → `sentence-transformers/all-MiniLM-L6-v2` | Dense embedding model used when no `USING MODEL` clause is given | +| `verify` | `bool \| str` | `True` | TLS verification setting; use `False` to skip verification or a CA bundle path for internal/self-signed certificates | | `default_dense_vector_name` | `str` | `"dense"` | Dense vector name used when QQL creates a collection and no explicit `USING VECTOR` name is given | | `default_sparse_vector_name` | `str` | `"sparse"` | Sparse vector name used when QQL creates a hybrid collection and no explicit sparse vector name is given | @@ -200,8 +225,8 @@ for hit in result.data: print(hit["score"], hit["payload"]) ``` -`run_query()` accepts the same `url`, `secret`, and `default_model` parameters -as `Connection.__init__()`. +`run_query()` accepts the same `url`, `secret`, `default_model`, and `verify` +parameters as `Connection.__init__()`. --- diff --git a/docs/reference.md b/docs/reference.md index ce9734e..a9d4a07 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -133,7 +133,8 @@ The connection config is stored at `~/.qql/config.json`: { "url": "http://localhost:6333", "secret": null, - "default_model": "sentence-transformers/all-MiniLM-L6-v2" + "default_model": "sentence-transformers/all-MiniLM-L6-v2", + "verify": true } ``` @@ -142,6 +143,7 @@ The connection config is stored at `~/.qql/config.json`: | `url` | Qdrant instance URL | | `secret` | API key (null if not required) | | `default_model` | Dense embedding model used when no `USING MODEL` clause is given | +| `verify` | TLS verification setting: `true`, `false`, or a custom CA bundle path from `--ca-cert` | You can edit this file directly to change the default model without reconnecting. diff --git a/src/qql/__init__.py b/src/qql/__init__.py index deeb737..da43725 100644 --- a/src/qql/__init__.py +++ b/src/qql/__init__.py @@ -42,6 +42,7 @@ def run_query( url: str = "http://localhost:6333", secret: str | None = None, default_model: str | None = None, + verify: bool | str = True, ) -> ExecutionResult: """One-shot convenience function kept for backward compatibility. @@ -55,5 +56,10 @@ def run_query( with Connection(url, secret=secret) as conn: result = conn.run_query(query) """ - with Connection(url=url, secret=secret, default_model=default_model) as conn: + with Connection( + url=url, + secret=secret, + default_model=default_model, + verify=verify, + ) as conn: return conn.run_query(query) diff --git a/src/qql/cli.py b/src/qql/cli.py index c573f14..36f266a 100644 --- a/src/qql/cli.py +++ b/src/qql/cli.py @@ -173,20 +173,44 @@ def main(ctx: click.Context) -> None: @main.command() @click.option("--url", required=True, help="Qdrant instance URL, e.g. http://localhost:6333") @click.option("--secret", default=None, help="API key / secret (optional)") -def connect(url: str, secret: str | None) -> None: +@click.option( + "--verify/--no-verify", + default=True, + show_default=True, + help="Verify SSL/TLS certificate (disable for self-signed certs).", +) +@click.option( + "--ca-cert", + default=None, + type=click.Path(exists=True, readable=True, dir_okay=False, resolve_path=True), + help="Path to a custom CA certificate bundle (PEM).", +) +def connect( + url: str, + secret: str | None, + verify: bool, + ca_cert: str | None, +) -> None: """Connect to a Qdrant instance and launch the QQL shell.""" from qdrant_client import QdrantClient + if ca_cert and not verify: + raise click.UsageError("--ca-cert cannot be used with --no-verify.") + + verify_val: bool | str = ca_cert if ca_cert else verify + console.print(f"Connecting to [bold]{url}[/bold]...") try: - client = QdrantClient(url=url, api_key=secret) - client.get_collections() # validate connection + client = QdrantClient(url=url, api_key=secret, verify=verify_val) + client.get_collections() except Exception as e: err_console.print(f"[bold red]Connection failed:[/bold red] {e}") sys.exit(1) + else: + client.close() - cfg = QQLConfig(url=url, secret=secret) + cfg = QQLConfig(url=url, secret=secret, verify=verify_val) save_config(cfg) console.print("[bold green]Connected.[/bold green] Config saved to ~/.qql/config.json\n") _launch_repl(cfg) @@ -228,7 +252,7 @@ def execute(file: str, stop_on_error: bool) -> None: sys.exit(1) try: - client = QdrantClient(url=cfg.url, api_key=cfg.secret) + client = QdrantClient(url=cfg.url, api_key=cfg.secret, verify=cfg.verify) client.get_collections() except Exception as e: err_console.print(f"[bold red]Connection failed:[/bold red] {e}") @@ -286,7 +310,7 @@ def dump(collection: str, output: str, batch_size: int) -> None: sys.exit(1) try: - client = QdrantClient(url=cfg.url, api_key=cfg.secret) + client = QdrantClient(url=cfg.url, api_key=cfg.secret, verify=cfg.verify) client.get_collections() except Exception as e: err_console.print(f"[bold red]Connection failed:[/bold red] {e}") @@ -319,7 +343,7 @@ def _launch_repl(cfg: QQLConfig) -> None: from qdrant_client import QdrantClient try: - client = QdrantClient(url=cfg.url, api_key=cfg.secret) + client = QdrantClient(url=cfg.url, api_key=cfg.secret, verify=cfg.verify) client.get_collections() except Exception as e: err_console.print(f"[bold red]Could not connect to {cfg.url}:[/bold red] {e}") diff --git a/src/qql/config.py b/src/qql/config.py index 8c41074..652e89a 100644 --- a/src/qql/config.py +++ b/src/qql/config.py @@ -19,6 +19,7 @@ class QQLConfig: default_model: str = DEFAULT_MODEL default_dense_vector_name: str = DEFAULT_DENSE_VECTOR_NAME default_sparse_vector_name: str = DEFAULT_SPARSE_VECTOR_NAME + verify: bool | str = True def save_config(cfg: QQLConfig) -> None: @@ -43,6 +44,7 @@ def load_config() -> QQLConfig | None: default_sparse_vector_name=data.get( "default_sparse_vector_name", DEFAULT_SPARSE_VECTOR_NAME ), + verify=data.get("verify", True), ) diff --git a/src/qql/connection.py b/src/qql/connection.py index e51f19f..fe72824 100644 --- a/src/qql/connection.py +++ b/src/qql/connection.py @@ -51,6 +51,7 @@ def __init__( url: str = "http://localhost:6333", secret: str | None = None, default_model: str | None = None, + verify: bool | str = True, ) -> None: """Create a connection to a Qdrant instance. @@ -60,6 +61,9 @@ def __init__( default_model: Dense embedding model used when no ``USING MODEL`` clause is specified. Defaults to ``sentence-transformers/all-MiniLM-L6-v2``. + verify: SSL certificate verification. Set to ``False`` to skip + verification for self-signed/internal certificates, or pass + a path to a custom CA bundle (default: ``True``). """ from qdrant_client import QdrantClient @@ -67,8 +71,9 @@ def __init__( url=url, secret=secret, default_model=default_model or DEFAULT_MODEL, + verify=verify, ) - self._client = QdrantClient(url=url, api_key=secret) + self._client = QdrantClient(url=url, api_key=secret, verify=verify) self._executor = Executor(self._client, self._config) # ── Public API ──────────────────────────────────────────────────────── diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..074d1d6 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,94 @@ +"""Tests for the Click CLI surface.""" + +from click.testing import CliRunner + +from qql import QQLConfig +from qql.cli import main + + +def test_connect_saves_no_verify_config(mocker): + mock_client = mocker.MagicMock() + mock_client_cls = mocker.patch("qdrant_client.QdrantClient", return_value=mock_client) + save_config = mocker.patch("qql.cli.save_config") + launch_repl = mocker.patch("qql.cli._launch_repl") + + result = CliRunner().invoke( + main, + ["connect", "--url", "https://internal.example.io", "--no-verify"], + ) + + assert result.exit_code == 0 + mock_client_cls.assert_called_once_with( + url="https://internal.example.io", api_key=None, verify=False + ) + save_config.assert_called_once_with( + QQLConfig(url="https://internal.example.io", secret=None, verify=False) + ) + launch_repl.assert_called_once() + + +def test_connect_saves_custom_ca_bundle_config(tmp_path, mocker): + ca_cert = tmp_path / "internal-ca.pem" + ca_cert.write_text("certificate") + mock_client = mocker.MagicMock() + mock_client_cls = mocker.patch("qdrant_client.QdrantClient", return_value=mock_client) + save_config = mocker.patch("qql.cli.save_config") + mocker.patch("qql.cli._launch_repl") + + result = CliRunner().invoke( + main, + [ + "connect", + "--url", + "https://internal.example.io", + "--ca-cert", + str(ca_cert), + ], + ) + + assert result.exit_code == 0 + verify = str(ca_cert.resolve()) + mock_client_cls.assert_called_once_with( + url="https://internal.example.io", api_key=None, verify=verify + ) + save_config.assert_called_once_with( + QQLConfig(url="https://internal.example.io", secret=None, verify=verify) + ) + + +def test_connect_rejects_ca_bundle_when_verification_is_disabled(tmp_path): + ca_cert = tmp_path / "internal-ca.pem" + ca_cert.write_text("certificate") + + result = CliRunner().invoke( + main, + [ + "connect", + "--url", + "https://internal.example.io", + "--no-verify", + "--ca-cert", + str(ca_cert), + ], + ) + + assert result.exit_code != 0 + assert "--ca-cert cannot be used with --no-verify" in result.output + + +def test_execute_uses_saved_verify_config(tmp_path, mocker): + script = tmp_path / "script.qql" + script.write_text("SHOW COLLECTIONS") + cfg = QQLConfig(url="https://internal.example.io", secret="s3cr3t", verify=False) + mock_client = mocker.MagicMock() + mock_client_cls = mocker.patch("qdrant_client.QdrantClient", return_value=mock_client) + mocker.patch("qql.cli.load_config", return_value=cfg) + run_script = mocker.patch("qql.script.run_script", return_value=(1, 0)) + + result = CliRunner().invoke(main, ["execute", str(script)]) + + assert result.exit_code == 0 + mock_client_cls.assert_called_once_with( + url="https://internal.example.io", api_key="s3cr3t", verify=False + ) + run_script.assert_called_once() diff --git a/tests/test_connection.py b/tests/test_connection.py index c209698..2278002 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -23,9 +23,27 @@ def test_custom_url_and_secret_passed_to_qdrant_client(self, mocker): mock_client_cls = mocker.patch("qdrant_client.QdrantClient") Connection("https://cloud.example.io", secret="s3cr3t") mock_client_cls.assert_called_once_with( - url="https://cloud.example.io", api_key="s3cr3t" + url="https://cloud.example.io", api_key="s3cr3t", verify=True ) + def test_ssl_verify_option_passed_to_qdrant_client(self, mocker): + mock_client_cls = mocker.patch("qdrant_client.QdrantClient") + conn = Connection("https://internal.example.io", verify=False) + mock_client_cls.assert_called_once_with( + url="https://internal.example.io", api_key=None, verify=False + ) + assert conn.config.verify is False + + def test_custom_ca_bundle_passed_to_qdrant_client(self, mocker): + mock_client_cls = mocker.patch("qdrant_client.QdrantClient") + conn = Connection("https://internal.example.io", verify="/etc/ssl/internal-ca.pem") + mock_client_cls.assert_called_once_with( + url="https://internal.example.io", + api_key=None, + verify="/etc/ssl/internal-ca.pem", + ) + assert conn.config.verify == "/etc/ssl/internal-ca.pem" + def test_custom_default_model_stored_in_config(self, mocker): mocker.patch("qdrant_client.QdrantClient") conn = Connection("http://localhost:6333", default_model="BAAI/bge-small-en-v1.5") @@ -156,7 +174,7 @@ def test_run_query_delegates_to_connection(self, mocker): conn_cls = mocker.patch("qql.Connection", return_value=conn_instance) run_query("SHOW COLLECTIONS", url="http://localhost:6333") conn_cls.assert_called_once_with( - url="http://localhost:6333", secret=None, default_model=None + url="http://localhost:6333", secret=None, default_model=None, verify=True ) conn_instance.run_query.assert_called_once_with("SHOW COLLECTIONS")