mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-13 15:22:30 +00:00
Adds the client half of database-scoped named Secrets: a Secret is a
name and
an opaque value stored by the service, and a Function binds one to the
environment variable its library already reads. Secrets are addressed by
a
namespace path plus a name.
The UDF body is unchanged and stays portable — it reads `OPENAI_API_KEY`
the
way it always did, and the binding is what puts a value there:
```python
db.create_secret("openai-prod", os.environ["OPENAI_API_KEY"])
function = db.create_function(
analyze_caption,
secrets=[
EnvVarSecret(secret_name="openai-prod", env_variable="OPENAI_API_KEY")
],
)
function.secret_bindings # the Secret's name, never its value
```
- `create_secret` / `alter_secret` / `list_secrets` / `describe_secret`
/
`drop_secret` on sync, async and remote connections, with the pyo3
binding
and the Rust client behind them. Each takes `namespace_path`
keyword-only,
defaulting to the root.
- **There is no read API, by construction rather than by policy** — no
code
path returns a stored credential, and `describe_secret` answers with
metadata only.
- `EnvVarSecret` is a pure local constructor: it contacts no server, so
it
cannot fail on a Secret that does not exist. It exists so that a bare
string
in that position — which would be a credential — is a `TypeError` rather
than a plausible-looking mistake that reads identically in a diff.
- `create_function(..., secrets=[...])` carries the bindings as
`secret_bindings`: a list of `SecretBinding` tagged by `kind`, so a
later
delivery mode is a variant rather than a sibling field. The value never
travels — it is resolved by the service when the Function runs, which is
what
lets a rotation reach columns already pinned to an older
FunctionVersion.
- A binding names its Secret as a `SecretReference` of `{name,
namespace_path}`
rather than one joined string, so no delimiter has to be excluded from
every
name and segment forever, and `ClientConfig.id_delimiter` cannot
contradict
an identity built on a fixed separator.
- A root namespace is omitted from the request body rather than sent
empty, so
a root request is byte-identical to one from a client that predates
namespaces. Tests pin it.
This is the client surface the design's §4 describes; the service side
lives in
sophon.
**Previously split across two PRs.** Namespace addressing was #4151,
stacked on
this one; it is folded in here so the Secret identity contract — name,
namespace path, and the binding that carries both — is reviewable as one
piece
rather than as a shape introduced and then replaced.
## Identifier safety, merged from #4189
**#4189 is merged into this branch**, so the client half of Secrets and
the
guards on the identity it puts in the URL are one PR. What it added:
- Components are checked where the identifier is built, before a request
is
constructed. `create_secret("../jobs", value)` no longer resolves to
`/v1/jobs/create` and delivers a credential-bearing body to a route with
none
of this one's body suppression.
- Each component is percent-encoded and joined by the delimiter, so
nothing
inside a component can end the path segment or add one.
- A component may not be empty, a relative segment (`.`, `..`, and their
`%2e`
spellings), or the delimiter itself — the three ways a component erases
a
boundary the split has to recover. `["prod", ""]` joined to `prod$`,
which
reads back as `["prod"]`.
- `$` is the only accepted `id_delimiter`, refused at client
construction.
`ClientConfig.id_delimiter` remains, since the identifier grammar comes
from
the Lance REST catalog standard, but a value that would produce
identifiers no
service splits the caller's way is now an error where it was written.
- One `build_object_identifier` and one character set serve tables,
namespaces,
Secrets, Functions and materialized views.
Components are checked for *addressability*, not a character set: the
name's
own grammar stays each object's own, so a catalog database keeps the `/`
that
`RemoteCatalog::validate_name` allows.
## Known shortcoming
`secret_bindings` is omitted from a registration body when empty, so a
client
that binds nothing sends what a client without bindings sends. When a
client
does bind a Secret and the service does not know the field, the field is
ignored: registration succeeds, the returned version carries no
bindings, and
the Function fails at execution with the variable unset, far from the
call that
asked for it.
`ServerVersion` is how this codebase refuses a feature the service is
too old
for, and it gates five features already. It does not gate this one: it
is held
per table, and registering a Function is a database-level call. Noted at
the
field in `remote/db.rs`; wiring the gate is follow-up work.
**Tests:** lancedb lib 1340 passed, `first_class_function_slice1` 9,
`first_class_function_slice2` 3, plus Python tests across both slices.
Rebased onto `main` after #4176 (OCI Function identity), #4191 (`.`/`..`
table
names) and #4195 (remote catalogs).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01UfmeJ533rQDnPBkMtjerV6
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
141 lines
5.1 KiB
Python
141 lines
5.1 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
|
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
import json
|
|
from threading import Thread
|
|
|
|
import pytest
|
|
|
|
import lancedb
|
|
from lancedb.db import AsyncConnection, DBConnection
|
|
from lancedb.remote.errors import HttpError
|
|
|
|
|
|
@pytest.fixture
|
|
def catalog_server():
|
|
requests = []
|
|
responses = []
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def handle_request(self):
|
|
body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
|
|
requests.append(
|
|
(
|
|
self.path,
|
|
dict(self.headers.items()),
|
|
json.loads(body) if body else None,
|
|
)
|
|
)
|
|
status, response = responses.pop(0)
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.end_headers()
|
|
if status != 204:
|
|
self.wfile.write(json.dumps(response).encode())
|
|
|
|
do_GET = handle_request
|
|
do_POST = handle_request
|
|
|
|
with ThreadingHTTPServer(("127.0.0.1", 0), Handler) as server:
|
|
thread = Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
try:
|
|
yield f"http://127.0.0.1:{server.server_port}", requests, responses
|
|
finally:
|
|
server.shutdown()
|
|
thread.join()
|
|
|
|
|
|
def test_catalog_sync_scope_and_serialization(catalog_server):
|
|
endpoint, requests, responses = catalog_server
|
|
responses.extend(
|
|
[
|
|
(204, None),
|
|
(200, {}),
|
|
(200, {"tables": []}),
|
|
(200, {"tables": []}),
|
|
(200, {"namespaces": ["team/search"], "page_token": "next"}),
|
|
(204, None),
|
|
]
|
|
)
|
|
catalog = lancedb.connect_catalog(
|
|
endpoint,
|
|
api_key="secret",
|
|
sql_host_override="invalid://localhost",
|
|
client_config={
|
|
"extra_headers": {
|
|
"X-LanceDB-Database": "wrong",
|
|
"X-LanceDB-Database-Prefix": "wrong",
|
|
}
|
|
},
|
|
)
|
|
assert isinstance(catalog, lancedb.Catalog)
|
|
assert catalog.uri == endpoint
|
|
db = catalog.create_database("team/search", exist_ok=True)
|
|
assert isinstance(db, DBConnection)
|
|
with pytest.raises(ValueError, match="sql_host_override must use"):
|
|
db.execute_query_async("SELECT 1")
|
|
db = catalog.connect_database("team/search")
|
|
assert isinstance(db, DBConnection)
|
|
assert db.table_names() == []
|
|
restored = lancedb.deserialize_conn(db.serialize())
|
|
assert restored.sql_host_override == "invalid://localhost"
|
|
for connection in (db, restored):
|
|
with pytest.raises(ValueError, match="sql_host_override must use"):
|
|
connection.execute_query_async("SELECT 1")
|
|
assert restored.table_names() == []
|
|
page = catalog.list_databases(limit=1, page_token="a/b")
|
|
assert page == lancedb.ListDatabasesResponse(["team/search"], "next")
|
|
catalog.drop_database("team/search", ignore_missing=True)
|
|
assert requests[0][0] == "/v1/namespace/team%2Fsearch/create"
|
|
assert requests[0][2] == {"mode": "ExistOk"}
|
|
assert requests[1][0] == "/v1/namespace/team%2Fsearch/describe"
|
|
assert requests[4][0] == "/v1/namespace/$/list?limit=1&page_token=a%2Fb"
|
|
assert requests[5][2] == {"mode": "Skip", "behavior": "Restrict"}
|
|
for i, (_, headers, _) in enumerate(requests):
|
|
headers = {key.lower(): value for key, value in headers.items()}
|
|
assert headers.get("x-lancedb-database") == (
|
|
"team/search" if i in (2, 3) else None
|
|
)
|
|
assert "x-lancedb-database-prefix" not in headers
|
|
assert headers["x-api-key"] == "secret"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_catalog_async_and_errors(catalog_server):
|
|
endpoint, requests, responses = catalog_server
|
|
responses.extend(
|
|
[
|
|
(200, {}),
|
|
(200, {"tables": []}),
|
|
(404, {"error": "missing"}),
|
|
(409, {"error": "exists"}),
|
|
(400, {"error": "not empty"}),
|
|
(404, {"error": "missing"}),
|
|
]
|
|
)
|
|
catalog = await lancedb.connect_catalog_async(
|
|
endpoint, sql_host_override="invalid://localhost"
|
|
)
|
|
assert isinstance(catalog, lancedb.AsyncCatalog)
|
|
db = await catalog.connect_database("analytics")
|
|
assert isinstance(db, AsyncConnection)
|
|
with pytest.raises(ValueError, match="sql_host_override must use"):
|
|
await db.execute_query_async("SELECT 1")
|
|
assert await db.table_names() == []
|
|
with pytest.raises(ValueError, match="missing"):
|
|
await catalog.connect_database("missing")
|
|
with pytest.raises(ValueError, match="exists"):
|
|
await catalog.create_database("exists")
|
|
with pytest.raises(HttpError):
|
|
await catalog.drop_database("full")
|
|
await catalog.drop_database("missing", ignore_missing=True)
|
|
assert requests[4][2] == {"mode": "Fail", "behavior": "Restrict"}
|
|
|
|
|
|
@pytest.mark.parametrize("endpoint", ["/tmp/catalog", "s3://bucket", "db://db"])
|
|
def test_catalog_requires_remote_endpoint(endpoint):
|
|
with pytest.raises(ValueError, match="endpoint"):
|
|
lancedb.connect_catalog(endpoint)
|