mirror of
https://github.com/lancedb/lancedb.git
synced 2026-11-13 22:26:53 +00:00
refactor(secrets): one binding list with a kind, and split the secret writes
`secret_env_bindings` took the general noun for one delivery mode. A binding is
the concept; how it arrives is a property of one. `secret_bindings` is a list of
`SecretBinding`, tagged by `kind`, so a later mode is a variant rather than a
sibling field -- and the rules that are per-Function, like how many Secrets it
may bind, stay answerable from one place.
The cost of one field is that an unknown kind is a decode error unless it is
caught. It is caught, the way `PythonRuntimeSpec` catches an unknown runtime:
`Unrecognized { kind }`, `#[non_exhaustive]`, and the payload dropped rather
than retained because the client does not proxy catalog values. A test pins it
-- a `file` binding from a newer server decodes, reports its kind, and
round-trips as its discriminator without failing the version around it.
A list has no key order to inherit, and the list is in the version hash, so
`bind_secrets` sorts it: a caller's argument order is not part of what a
Function is.
The Secret is named under `secret_ref`, not `secret`: the service scans Job
payloads for credential-shaped keys and refuses one called `secret` whatever it
holds. That guard is worth more blunt than argued with.
`EnvVarSecret` names it the same way for the same reason: the field is
`secret_name`, not `secret`. In a binding both halves need saying -- which
variable, which Secret -- and the bare noun reads as the credential rather than
the name of one, which is the misreading the type exists to prevent.
`create_secret` and `alter_secret` also stop sharing a request shape. They are
different operations to the service -- one refuses an existing name, the other
requires it -- and either may grow a field the other has no meaning for. What
they share is posting a body that must not be logged, which is a function.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfmeJ533rQDnPBkMtjerV6
This commit is contained in:
co-authored by
Claude Opus 5
parent
be7968cd51
commit
3d66db7acc
@@ -722,7 +722,9 @@ class DBConnection(EnforceOverrides):
|
||||
db.create_function(
|
||||
analyze_caption,
|
||||
secrets=[
|
||||
EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")
|
||||
EnvVarSecret(
|
||||
secret_name="openai-prod", env_variable="OPENAI_API_KEY"
|
||||
)
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
@@ -227,6 +227,20 @@ class FunctionOutput(_OpenRemoteValue):
|
||||
fields: tuple[FunctionResultField, ...] = ()
|
||||
|
||||
|
||||
class SecretBinding(_RemoteValue):
|
||||
"""How a Secret reaches the Function that binds it.
|
||||
|
||||
One list rather than a field per delivery mode: a binding is the concept,
|
||||
and how it arrives is a property of one. ``kind`` is open, so a binding a
|
||||
newer service introduces decodes here instead of failing the whole
|
||||
FunctionVersion.
|
||||
"""
|
||||
|
||||
kind: str
|
||||
variable: Optional[str] = None
|
||||
secret_ref: Optional[str] = None
|
||||
|
||||
|
||||
class FunctionSignature(_RemoteValue):
|
||||
inputs: tuple[FunctionParameter, ...]
|
||||
output: FunctionOutput
|
||||
@@ -310,7 +324,7 @@ class FunctionVersion(_RemoteValue):
|
||||
runtime: PythonRuntimeSpec
|
||||
runtime_digest: str
|
||||
environment_digest: str
|
||||
secret_env_bindings: Mapping[str, str] = {}
|
||||
secret_bindings: tuple[SecretBinding, ...] = ()
|
||||
created_at: str
|
||||
|
||||
def __call__(self, **inputs: Any) -> FunctionApplication:
|
||||
@@ -375,7 +389,7 @@ class FunctionRegistrationRequest(_RemoteValue):
|
||||
"""Stable remote registration envelope produced by :func:`udf`.
|
||||
|
||||
Credential values deliberately have no field here. The only secret-shaped
|
||||
thing a client sends is ``secret_env_bindings``: the name of a Secret the
|
||||
thing a client sends is ``secret_bindings``: the name of a Secret the
|
||||
database already holds, which the remote service resolves at execution.
|
||||
"""
|
||||
|
||||
@@ -383,7 +397,7 @@ class FunctionRegistrationRequest(_RemoteValue):
|
||||
artifact: FunctionArtifactRequest
|
||||
signature: FunctionSignature
|
||||
runtime: PythonRuntimeSpec
|
||||
secret_env_bindings: Mapping[str, str] = {}
|
||||
secret_bindings: tuple[SecretBinding, ...] = ()
|
||||
|
||||
|
||||
class FunctionVersionRef(_OpenRemoteValue):
|
||||
@@ -1322,8 +1336,22 @@ class UdfDefinition:
|
||||
)
|
||||
if not bindings:
|
||||
return self._request
|
||||
resolved = {binding.env_variable: binding.secret for binding in bindings}
|
||||
return self._request._copy(update={"secret_env_bindings": resolved})
|
||||
# Sorted, because the list is carried in the FunctionVersion hash and a
|
||||
# caller's argument order is not part of what a Function is.
|
||||
resolved = tuple(
|
||||
sorted(
|
||||
(
|
||||
SecretBinding(
|
||||
kind="env",
|
||||
variable=binding.env_variable,
|
||||
secret_ref=binding.secret_name,
|
||||
)
|
||||
for binding in bindings
|
||||
),
|
||||
key=lambda binding: (binding.kind, binding.variable or ""),
|
||||
)
|
||||
)
|
||||
return self._request._copy(update={"secret_bindings": resolved})
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
return self._function(*args, **kwargs)
|
||||
|
||||
@@ -63,7 +63,7 @@ class EnvVarSecret:
|
||||
|
||||
Parameters
|
||||
----------
|
||||
secret : str
|
||||
secret_name : str
|
||||
The Secret's database-scoped name.
|
||||
env_variable : str
|
||||
The environment variable the Function reads it from.
|
||||
@@ -71,21 +71,23 @@ class EnvVarSecret:
|
||||
Examples
|
||||
--------
|
||||
>>> from lancedb import EnvVarSecret
|
||||
>>> binding = EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")
|
||||
>>> binding.secret, binding.env_variable
|
||||
>>> binding = EnvVarSecret(
|
||||
... secret_name="openai-prod", env_variable="OPENAI_API_KEY"
|
||||
... )
|
||||
>>> binding.secret_name, binding.env_variable
|
||||
('openai-prod', 'OPENAI_API_KEY')
|
||||
"""
|
||||
|
||||
__slots__ = ("_secret", "_env_variable")
|
||||
__slots__ = ("_secret_name", "_env_variable")
|
||||
|
||||
def __init__(self, secret: str, env_variable: str):
|
||||
self._secret = validate_secret_name(secret)
|
||||
def __init__(self, secret_name: str, env_variable: str):
|
||||
self._secret_name = validate_secret_name(secret_name)
|
||||
self._env_variable = validate_env_variable(env_variable)
|
||||
|
||||
@property
|
||||
def secret(self) -> str:
|
||||
def secret_name(self) -> str:
|
||||
"""The Secret's database-scoped name."""
|
||||
return self._secret
|
||||
return self._secret_name
|
||||
|
||||
@property
|
||||
def env_variable(self) -> str:
|
||||
@@ -94,19 +96,19 @@ class EnvVarSecret:
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"EnvVarSecret(secret={self._secret!r}, "
|
||||
f"EnvVarSecret(secret_name={self._secret_name!r}, "
|
||||
f"env_variable={self._env_variable!r})"
|
||||
)
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
return (
|
||||
isinstance(other, EnvVarSecret)
|
||||
and other._secret == self._secret
|
||||
and other._secret_name == self._secret_name
|
||||
and other._env_variable == self._env_variable
|
||||
)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((EnvVarSecret, self._secret, self._env_variable))
|
||||
return hash((EnvVarSecret, self._secret_name, self._env_variable))
|
||||
|
||||
|
||||
class SecretInfo:
|
||||
|
||||
@@ -13,6 +13,7 @@ from lancedb.functions import (
|
||||
FunctionBinding,
|
||||
FunctionVersion,
|
||||
PythonRuntimeSpec,
|
||||
SecretBinding,
|
||||
RefreshColumnResult,
|
||||
)
|
||||
from lancedb.table import AsyncTable
|
||||
@@ -110,7 +111,9 @@ def test_function_version_identity_is_immutable_and_exact():
|
||||
version = FunctionVersion.from_json(json.dumps(value))
|
||||
assert version.name == "embed"
|
||||
assert version.version == "fv_01K3EXACT"
|
||||
assert dict(version.secret_env_bindings) == {"HF_TOKEN": "hf-prod"}
|
||||
assert list(version.secret_bindings) == [
|
||||
SecretBinding(kind="env", variable="HF_TOKEN", secret_ref="hf-prod")
|
||||
]
|
||||
|
||||
with pytest.raises((TypeError, ValueError)):
|
||||
version.version = "fv_changed"
|
||||
@@ -299,17 +302,19 @@ def test_canonical_client_values_carry_bindings_and_no_credentials():
|
||||
json.dumps(job_result("remote_function_job.json"))
|
||||
)
|
||||
canonical = json.loads(version.to_canonical_json())
|
||||
assert canonical["secret_env_bindings"] == {"HF_TOKEN": "hf-prod"}
|
||||
assert canonical["secret_bindings"] == [
|
||||
{"kind": "env", "variable": "HF_TOKEN", "secret_ref": "hf-prod"}
|
||||
]
|
||||
assert_no_secret_values(canonical)
|
||||
|
||||
|
||||
def test_a_version_without_bindings_keeps_the_original_wire_shape():
|
||||
"""Every Function registered before Secrets existed serializes unchanged."""
|
||||
value = job_result("remote_function_job.json")
|
||||
del value["secret_env_bindings"]
|
||||
del value["secret_bindings"]
|
||||
version = FunctionVersion.from_json(json.dumps(value))
|
||||
assert dict(version.secret_env_bindings) == {}
|
||||
assert "secret_env_bindings" not in json.loads(version.to_canonical_json())
|
||||
assert list(version.secret_bindings) == []
|
||||
assert "secret_bindings" not in json.loads(version.to_canonical_json())
|
||||
|
||||
|
||||
class _FunctionDeclarationInner:
|
||||
|
||||
@@ -25,6 +25,7 @@ import pytest
|
||||
import lancedb
|
||||
from lancedb.functions import (
|
||||
PythonRuntimeSpec,
|
||||
SecretBinding,
|
||||
UdfDefinition,
|
||||
_canonical_arrow_type,
|
||||
_GRAMMAR_PRIMITIVES,
|
||||
@@ -84,7 +85,7 @@ def test_scalar_udf_matches_shared_registration_golden_and_remains_callable():
|
||||
def test_secret_bound_udf_matches_its_shared_registration_golden():
|
||||
assert analyze_caption(" hello ") == "hello"
|
||||
bound = analyze_caption.bind_secrets(
|
||||
[EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")]
|
||||
[EnvVarSecret(secret_name="openai-prod", env_variable="OPENAI_API_KEY")]
|
||||
)
|
||||
assert (
|
||||
bound.to_canonical_json()
|
||||
@@ -102,14 +103,14 @@ def test_an_unbound_request_carries_no_binding_at_all():
|
||||
whether or not a credential is later bound to it.
|
||||
"""
|
||||
unbound = json.loads(analyze_caption.registration_request.to_canonical_json())
|
||||
assert "secret_env_bindings" not in unbound
|
||||
assert "secret_bindings" not in unbound
|
||||
assert "OPENAI_API_KEY" not in json.dumps(unbound)
|
||||
|
||||
|
||||
def test_binding_a_secret_leaves_the_packaged_artifact_untouched():
|
||||
"""The artifact is source bytes and nothing else, with or without secrets."""
|
||||
bound = analyze_caption.bind_secrets(
|
||||
[EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")]
|
||||
[EnvVarSecret(secret_name="openai-prod", env_variable="OPENAI_API_KEY")]
|
||||
)
|
||||
assert bound.artifact == analyze_caption.registration_request.artifact
|
||||
assert bound.artifact.digest == analyze_caption.registration_request.artifact.digest
|
||||
@@ -122,7 +123,7 @@ def test_a_function_declaring_no_secret_is_registered_exactly_as_before():
|
||||
== normalize_score.registration_request.to_canonical_json()
|
||||
)
|
||||
assert (
|
||||
"secret_env_bindings"
|
||||
"secret_bindings"
|
||||
not in normalize_score.registration_request.to_canonical_json()
|
||||
)
|
||||
|
||||
@@ -131,8 +132,10 @@ def test_a_function_binds_each_variable_once():
|
||||
with pytest.raises(ValueError, match="binds each environment variable once"):
|
||||
analyze_caption.bind_secrets(
|
||||
[
|
||||
EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY"),
|
||||
EnvVarSecret(secret="openai-staging", env_variable="OPENAI_API_KEY"),
|
||||
EnvVarSecret(secret_name="openai-prod", env_variable="OPENAI_API_KEY"),
|
||||
EnvVarSecret(
|
||||
secret_name="openai-staging", env_variable="OPENAI_API_KEY"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -141,7 +144,7 @@ def test_bindings_may_not_collide_with_plain_configuration():
|
||||
"""`env` is stored with the Function; a Secret is not. Refuse, do not pick."""
|
||||
with pytest.raises(ValueError, match="must be disjoint"):
|
||||
analyze_caption.bind_secrets(
|
||||
[EnvVarSecret(secret="mode-prod", env_variable="MODE")]
|
||||
[EnvVarSecret(secret_name="mode-prod", env_variable="MODE")]
|
||||
)
|
||||
|
||||
|
||||
@@ -161,14 +164,16 @@ def test_a_binding_envelope_reaches_the_service_for_it_to_judge():
|
||||
client_config={"retry_config": {"retries": 0}},
|
||||
)
|
||||
bindings = [
|
||||
EnvVarSecret(secret=f"secret-{index}", env_variable=f"TOKEN_{index}")
|
||||
EnvVarSecret(secret_name=f"secret-{index}", env_variable=f"TOKEN_{index}")
|
||||
for index in range(17)
|
||||
]
|
||||
db.create_function(normalize_score, secrets=bindings)
|
||||
|
||||
sent = state["requests"][0][1]
|
||||
assert len(sent["secret_env_bindings"]) == 17
|
||||
assert sent["secret_env_bindings"]["TOKEN_0"] == "secret-0"
|
||||
assert len(sent["secret_bindings"]) == 17
|
||||
assert {"kind": "env", "variable": "TOKEN_0", "secret_ref": "secret-0"} in sent[
|
||||
"secret_bindings"
|
||||
]
|
||||
|
||||
|
||||
_SECRET_DEBUG_LOG_SOURCE = """
|
||||
@@ -249,7 +254,7 @@ def test_a_credential_value_is_rejected_in_the_binding_position():
|
||||
)
|
||||
def test_a_binding_validates_both_names_locally(secret, variable, message):
|
||||
with pytest.raises(ValueError, match=message):
|
||||
EnvVarSecret(secret=secret, env_variable=variable)
|
||||
EnvVarSecret(secret_name=secret, env_variable=variable)
|
||||
|
||||
|
||||
def test_a_secret_name_admits_what_a_namespace_name_does():
|
||||
@@ -262,12 +267,12 @@ def test_a_secret_name_admits_what_a_namespace_name_does():
|
||||
edges are too.
|
||||
"""
|
||||
for name in ["openai.prod.v1", ".hidden", "_internal", "-lead", "trailing."]:
|
||||
binding = EnvVarSecret(secret=name, env_variable="OPENAI_API_KEY")
|
||||
assert binding.secret == name
|
||||
binding = EnvVarSecret(secret_name=name, env_variable="OPENAI_API_KEY")
|
||||
assert binding.secret_name == name
|
||||
|
||||
for name in ["", "with/slash", "with$delimiter", "a" * 256]:
|
||||
with pytest.raises(ValueError, match="invalid Secret name"):
|
||||
EnvVarSecret(secret=name, env_variable="OPENAI_API_KEY")
|
||||
EnvVarSecret(secret_name=name, env_variable="OPENAI_API_KEY")
|
||||
|
||||
|
||||
def _main_udf_source(
|
||||
@@ -1447,7 +1452,7 @@ def _mock_remote_function_catalog():
|
||||
"runtime": body["runtime"],
|
||||
"runtime_digest": "sha256:runtime",
|
||||
"environment_digest": "sha256:environment",
|
||||
"secret_env_bindings": body.get("secret_env_bindings", {}),
|
||||
"secret_bindings": body.get("secret_bindings", []),
|
||||
"created_at": "2026-08-21T00:00:00Z",
|
||||
}
|
||||
response = {"job_id": "job-register"}
|
||||
@@ -1557,19 +1562,25 @@ def test_remote_registration_sends_bindings_and_never_a_credential():
|
||||
)
|
||||
created = db.create_function(
|
||||
analyze_caption,
|
||||
secrets=[EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")],
|
||||
secrets=[
|
||||
EnvVarSecret(secret_name="openai-prod", env_variable="OPENAI_API_KEY")
|
||||
],
|
||||
)
|
||||
|
||||
assert dict(created.secret_env_bindings) == {"OPENAI_API_KEY": "openai-prod"}
|
||||
assert list(created.secret_bindings) == [
|
||||
SecretBinding(kind="env", variable="OPENAI_API_KEY", secret_ref="openai-prod")
|
||||
]
|
||||
path, create_request = state["requests"][0]
|
||||
assert path == "/v1/function/analyze_caption/create"
|
||||
assert create_request["secret_env_bindings"] == {"OPENAI_API_KEY": "openai-prod"}
|
||||
assert create_request["secret_bindings"] == [
|
||||
{"kind": "env", "variable": "OPENAI_API_KEY", "secret_ref": "openai-prod"}
|
||||
]
|
||||
# The request names a Secret and carries nothing that could be one. The
|
||||
# Function's own name is the path identifier rather than a body field, so
|
||||
# it is the one key the body does not repeat.
|
||||
expected = json.loads(
|
||||
analyze_caption.bind_secrets(
|
||||
[EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")]
|
||||
[EnvVarSecret(secret_name="openai-prod", env_variable="OPENAI_API_KEY")]
|
||||
).to_canonical_json()
|
||||
)
|
||||
assert expected.pop("name") == "analyze_caption"
|
||||
@@ -1609,8 +1620,8 @@ def test_building_a_binding_contacts_no_server():
|
||||
clear error rather than a client-side check that was already stale.
|
||||
"""
|
||||
with _mock_remote_function_catalog() as (_host, state):
|
||||
binding = EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")
|
||||
assert binding.secret == "openai-prod"
|
||||
binding = EnvVarSecret(secret_name="openai-prod", env_variable="OPENAI_API_KEY")
|
||||
assert binding.secret_name == "openai-prod"
|
||||
assert binding.env_variable == "OPENAI_API_KEY"
|
||||
|
||||
assert state["requests"] == []
|
||||
|
||||
Reference in New Issue
Block a user