mirror of
https://github.com/lancedb/lancedb.git
synced 2026-09-12 08:12:28 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b00c6d4001 |
@@ -153,11 +153,17 @@ class Connection(object):
|
|||||||
async def get_function(self, name: str, version: str) -> str: ...
|
async def get_function(self, name: str, version: str) -> str: ...
|
||||||
async def list_functions(self) -> List[str]: ...
|
async def list_functions(self) -> List[str]: ...
|
||||||
async def drop_function(self, name: str, version: str) -> bool: ...
|
async def drop_function(self, name: str, version: str) -> bool: ...
|
||||||
async def create_secret(self, name: str, value: str) -> None: ...
|
async def create_secret(
|
||||||
async def alter_secret(self, name: str, value: str) -> None: ...
|
self, name: str, value: str, namespace_path: List[str]
|
||||||
async def list_secrets(self) -> List[str]: ...
|
) -> None: ...
|
||||||
async def drop_secret(self, name: str) -> None: ...
|
async def alter_secret(
|
||||||
async def describe_secret(self, name: str) -> Dict[str, str]: ...
|
self, name: str, value: str, namespace_path: List[str]
|
||||||
|
) -> None: ...
|
||||||
|
async def list_secrets(self, namespace_path: List[str]) -> List[str]: ...
|
||||||
|
async def drop_secret(self, name: str, namespace_path: List[str]) -> None: ...
|
||||||
|
async def describe_secret(
|
||||||
|
self, name: str, namespace_path: List[str]
|
||||||
|
) -> Dict[str, str]: ...
|
||||||
async def list_jobs(self) -> List[JobInfo]: ...
|
async def list_jobs(self) -> List[JobInfo]: ...
|
||||||
async def cancel_job(self, job_id: str) -> bool: ...
|
async def cancel_job(self, job_id: str) -> bool: ...
|
||||||
async def execute_query_async(
|
async def execute_query_async(
|
||||||
|
|||||||
+70
-26
@@ -58,7 +58,12 @@ from .materialized_view import (
|
|||||||
SelectArg,
|
SelectArg,
|
||||||
normalize_select,
|
normalize_select,
|
||||||
)
|
)
|
||||||
from .secrets import EnvVarSecret, SecretInfo, validate_secret_name
|
from .secrets import (
|
||||||
|
EnvVarSecret,
|
||||||
|
SecretInfo,
|
||||||
|
validate_namespace_path,
|
||||||
|
validate_secret_name,
|
||||||
|
)
|
||||||
from .table import (
|
from .table import (
|
||||||
AsyncTable,
|
AsyncTable,
|
||||||
LanceTable,
|
LanceTable,
|
||||||
@@ -779,7 +784,9 @@ class DBConnection(EnforceOverrides):
|
|||||||
"Function catalog operations are not supported for this connection type"
|
"Function catalog operations are not supported for this connection type"
|
||||||
)
|
)
|
||||||
|
|
||||||
def create_secret(self, name: str, value: str) -> None:
|
def create_secret(
|
||||||
|
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> None:
|
||||||
"""Create a named Secret in this database.
|
"""Create a named Secret in this database.
|
||||||
|
|
||||||
Fails if the name is taken, so a create never silently becomes a
|
Fails if the name is taken, so a create never silently becomes a
|
||||||
@@ -791,7 +798,9 @@ class DBConnection(EnforceOverrides):
|
|||||||
"Secret operations are not supported for this connection type"
|
"Secret operations are not supported for this connection type"
|
||||||
)
|
)
|
||||||
|
|
||||||
def alter_secret(self, name: str, value: str) -> None:
|
def alter_secret(
|
||||||
|
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> None:
|
||||||
"""Replace the credential behind an existing Secret.
|
"""Replace the credential behind an existing Secret.
|
||||||
|
|
||||||
Fails if it does not exist. Every Function bound to the Secret uses the
|
Fails if it does not exist. Every Function bound to the Secret uses the
|
||||||
@@ -803,7 +812,7 @@ class DBConnection(EnforceOverrides):
|
|||||||
"Secret operations are not supported for this connection type"
|
"Secret operations are not supported for this connection type"
|
||||||
)
|
)
|
||||||
|
|
||||||
def list_secrets(self) -> List[str]:
|
def list_secrets(self, *, namespace_path: Optional[List[str]] = None) -> List[str]:
|
||||||
"""The names of every Secret in this database.
|
"""The names of every Secret in this database.
|
||||||
|
|
||||||
Names only. No method returns a stored credential, by construction
|
Names only. No method returns a stored credential, by construction
|
||||||
@@ -813,7 +822,9 @@ class DBConnection(EnforceOverrides):
|
|||||||
"Secret operations are not supported for this connection type"
|
"Secret operations are not supported for this connection type"
|
||||||
)
|
)
|
||||||
|
|
||||||
def drop_secret(self, name: str) -> None:
|
def drop_secret(
|
||||||
|
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> None:
|
||||||
"""Drop a Secret.
|
"""Drop a Secret.
|
||||||
|
|
||||||
Functions bound to it fail at their next job, naming the Secret; that
|
Functions bound to it fail at their next job, naming the Secret; that
|
||||||
@@ -825,7 +836,9 @@ class DBConnection(EnforceOverrides):
|
|||||||
"Secret operations are not supported for this connection type"
|
"Secret operations are not supported for this connection type"
|
||||||
)
|
)
|
||||||
|
|
||||||
def describe_secret(self, name: str) -> SecretInfo:
|
def describe_secret(
|
||||||
|
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> SecretInfo:
|
||||||
"""What this database records about a Secret: name and timestamps.
|
"""What this database records about a Secret: name and timestamps.
|
||||||
|
|
||||||
Never the value -- there is no code path that could return one. Local
|
Never the value -- there is no code path that could return one. Local
|
||||||
@@ -1569,24 +1582,32 @@ class LanceDBConnection(DBConnection):
|
|||||||
return LOOP.run(self._conn.drop_function(name, version=version))
|
return LOOP.run(self._conn.drop_function(name, version=version))
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def create_secret(self, name: str, value: str) -> None:
|
def create_secret(
|
||||||
LOOP.run(self._conn.create_secret(name, value))
|
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> None:
|
||||||
|
LOOP.run(self._conn.create_secret(name, value, namespace_path=namespace_path))
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def alter_secret(self, name: str, value: str) -> None:
|
def alter_secret(
|
||||||
LOOP.run(self._conn.alter_secret(name, value))
|
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> None:
|
||||||
|
LOOP.run(self._conn.alter_secret(name, value, namespace_path=namespace_path))
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def list_secrets(self) -> List[str]:
|
def list_secrets(self, *, namespace_path: Optional[List[str]] = None) -> List[str]:
|
||||||
return LOOP.run(self._conn.list_secrets())
|
return LOOP.run(self._conn.list_secrets(namespace_path=namespace_path))
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def drop_secret(self, name: str) -> None:
|
def drop_secret(
|
||||||
LOOP.run(self._conn.drop_secret(name))
|
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> None:
|
||||||
|
LOOP.run(self._conn.drop_secret(name, namespace_path=namespace_path))
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def describe_secret(self, name: str) -> SecretInfo:
|
def describe_secret(
|
||||||
return LOOP.run(self._conn.describe_secret(name))
|
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> SecretInfo:
|
||||||
|
return LOOP.run(self._conn.describe_secret(name, namespace_path=namespace_path))
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def list_jobs(self) -> List[JobInfo]:
|
def list_jobs(self) -> List[JobInfo]:
|
||||||
@@ -2418,34 +2439,57 @@ class AsyncConnection(object):
|
|||||||
"""Drop one exact immutable Function version from the remote catalog."""
|
"""Drop one exact immutable Function version from the remote catalog."""
|
||||||
return await self._inner.drop_function(name, version)
|
return await self._inner.drop_function(name, version)
|
||||||
|
|
||||||
async def create_secret(self, name: str, value: str) -> None:
|
async def create_secret(
|
||||||
|
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> None:
|
||||||
"""Create a named Secret in this database.
|
"""Create a named Secret in this database.
|
||||||
|
|
||||||
Fails if the name is taken, so a create never silently becomes a
|
Fails if the name is taken, so a create never silently becomes a
|
||||||
rotation. Nothing reads the value back.
|
rotation. Nothing reads the value back.
|
||||||
"""
|
"""
|
||||||
await self._inner.create_secret(validate_secret_name(name), value)
|
await self._inner.create_secret(
|
||||||
|
validate_secret_name(name),
|
||||||
|
value,
|
||||||
|
list(validate_namespace_path(namespace_path)),
|
||||||
|
)
|
||||||
|
|
||||||
async def alter_secret(self, name: str, value: str) -> None:
|
async def alter_secret(
|
||||||
|
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> None:
|
||||||
"""Replace the credential behind an existing Secret.
|
"""Replace the credential behind an existing Secret.
|
||||||
|
|
||||||
Fails if it does not exist. Bound Functions use the new value from
|
Fails if it does not exist. Bound Functions use the new value from
|
||||||
their next job, with no new Function version.
|
their next job, with no new Function version.
|
||||||
"""
|
"""
|
||||||
await self._inner.alter_secret(validate_secret_name(name), value)
|
await self._inner.alter_secret(
|
||||||
|
validate_secret_name(name),
|
||||||
|
value,
|
||||||
|
list(validate_namespace_path(namespace_path)),
|
||||||
|
)
|
||||||
|
|
||||||
async def list_secrets(self) -> List[str]:
|
async def list_secrets(
|
||||||
|
self, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> List[str]:
|
||||||
"""The names of every Secret in this database. Names only."""
|
"""The names of every Secret in this database. Names only."""
|
||||||
return await self._inner.list_secrets()
|
return await self._inner.list_secrets(
|
||||||
|
list(validate_namespace_path(namespace_path))
|
||||||
|
)
|
||||||
|
|
||||||
async def drop_secret(self, name: str) -> None:
|
async def drop_secret(
|
||||||
|
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> None:
|
||||||
"""Drop a Secret. Bound Functions fail at their next job."""
|
"""Drop a Secret. Bound Functions fail at their next job."""
|
||||||
await self._inner.drop_secret(validate_secret_name(name))
|
await self._inner.drop_secret(
|
||||||
|
validate_secret_name(name), list(validate_namespace_path(namespace_path))
|
||||||
|
)
|
||||||
|
|
||||||
async def describe_secret(self, name: str) -> SecretInfo:
|
async def describe_secret(
|
||||||
|
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> SecretInfo:
|
||||||
"""What this database records about a Secret. Never the value."""
|
"""What this database records about a Secret. Never the value."""
|
||||||
name, created_at_millis, updated_at_millis = await self._inner.describe_secret(
|
name, created_at_millis, updated_at_millis = await self._inner.describe_secret(
|
||||||
validate_secret_name(name)
|
validate_secret_name(name),
|
||||||
|
list(validate_namespace_path(namespace_path)),
|
||||||
)
|
)
|
||||||
return SecretInfo(
|
return SecretInfo(
|
||||||
name=name,
|
name=name,
|
||||||
|
|||||||
@@ -227,6 +227,19 @@ class FunctionOutput(_OpenRemoteValue):
|
|||||||
fields: tuple[FunctionResultField, ...] = ()
|
fields: tuple[FunctionResultField, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
class SecretReference(_RemoteValue):
|
||||||
|
"""Where a Secret lives, carried as its parts rather than as one string.
|
||||||
|
|
||||||
|
A joined id would need a delimiter, and a delimiter has to be excluded from
|
||||||
|
every name and segment forever, agreed on by both sides, and re-agreed each
|
||||||
|
time either grows a new way to be configured. Naming the parts settles all
|
||||||
|
of that: nothing here is parsed, so nothing can parse two ways.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
namespace_path: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
class SecretBinding(_RemoteValue):
|
class SecretBinding(_RemoteValue):
|
||||||
"""How a Secret reaches the Function that binds it.
|
"""How a Secret reaches the Function that binds it.
|
||||||
|
|
||||||
@@ -238,7 +251,7 @@ class SecretBinding(_RemoteValue):
|
|||||||
|
|
||||||
kind: str
|
kind: str
|
||||||
variable: Optional[str] = None
|
variable: Optional[str] = None
|
||||||
secret_ref: Optional[str] = None
|
secret_ref: Optional[SecretReference] = None
|
||||||
|
|
||||||
|
|
||||||
class FunctionSignature(_RemoteValue):
|
class FunctionSignature(_RemoteValue):
|
||||||
@@ -1344,7 +1357,10 @@ class UdfDefinition:
|
|||||||
SecretBinding(
|
SecretBinding(
|
||||||
kind="env",
|
kind="env",
|
||||||
variable=binding.env_variable,
|
variable=binding.env_variable,
|
||||||
secret_ref=binding.secret,
|
secret_ref=SecretReference(
|
||||||
|
name=binding.secret,
|
||||||
|
namespace_path=tuple(binding.namespace_path),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
for binding in bindings
|
for binding in bindings
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -778,24 +778,32 @@ class RemoteDBConnection(DBConnection):
|
|||||||
return LOOP.run(self._conn.drop_function(name, version=version))
|
return LOOP.run(self._conn.drop_function(name, version=version))
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def create_secret(self, name: str, value: str) -> None:
|
def create_secret(
|
||||||
LOOP.run(self._conn.create_secret(name, value))
|
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> None:
|
||||||
|
LOOP.run(self._conn.create_secret(name, value, namespace_path=namespace_path))
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def alter_secret(self, name: str, value: str) -> None:
|
def alter_secret(
|
||||||
LOOP.run(self._conn.alter_secret(name, value))
|
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> None:
|
||||||
|
LOOP.run(self._conn.alter_secret(name, value, namespace_path=namespace_path))
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def describe_secret(self, name: str) -> SecretInfo:
|
def describe_secret(
|
||||||
return LOOP.run(self._conn.describe_secret(name))
|
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> SecretInfo:
|
||||||
|
return LOOP.run(self._conn.describe_secret(name, namespace_path=namespace_path))
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def list_secrets(self) -> List[str]:
|
def list_secrets(self, *, namespace_path: Optional[List[str]] = None) -> List[str]:
|
||||||
return LOOP.run(self._conn.list_secrets())
|
return LOOP.run(self._conn.list_secrets(namespace_path=namespace_path))
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def drop_secret(self, name: str) -> None:
|
def drop_secret(
|
||||||
LOOP.run(self._conn.drop_secret(name))
|
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||||
|
) -> None:
|
||||||
|
LOOP.run(self._conn.drop_secret(name, namespace_path=namespace_path))
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def list_jobs(self) -> List["JobInfo"]:
|
def list_jobs(self) -> List["JobInfo"]:
|
||||||
|
|||||||
@@ -31,6 +31,31 @@ def validate_secret_name(name: str) -> str:
|
|||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def validate_namespace_path(namespace_path=None):
|
||||||
|
"""Check a namespace path locally and return it as a tuple.
|
||||||
|
|
||||||
|
``None`` and ``[]`` both mean the root namespace. Segments follow the same
|
||||||
|
rule as Secret names: a binding carries the path and the name as separate
|
||||||
|
fields, so neither is ever parsed out of the other.
|
||||||
|
"""
|
||||||
|
if namespace_path is None:
|
||||||
|
return ()
|
||||||
|
if isinstance(namespace_path, str):
|
||||||
|
raise TypeError(
|
||||||
|
"namespace_path must be a list of segments, not a string; "
|
||||||
|
f"did you mean [{namespace_path!r}]?"
|
||||||
|
)
|
||||||
|
segments = tuple(namespace_path)
|
||||||
|
for segment in segments:
|
||||||
|
if not isinstance(segment, str):
|
||||||
|
raise TypeError(
|
||||||
|
f"namespace path segment must be a string, not {type(segment).__name__}"
|
||||||
|
)
|
||||||
|
if not _SECRET_NAME.fullmatch(segment):
|
||||||
|
raise ValueError(f"invalid namespace path segment: {segment!r}")
|
||||||
|
return segments
|
||||||
|
|
||||||
|
|
||||||
def validate_env_variable(name: str) -> str:
|
def validate_env_variable(name: str) -> str:
|
||||||
"""Check an environment variable name locally and return it unchanged."""
|
"""Check an environment variable name locally and return it unchanged."""
|
||||||
if not isinstance(name, str):
|
if not isinstance(name, str):
|
||||||
@@ -76,11 +101,12 @@ class EnvVarSecret:
|
|||||||
('openai-prod', 'OPENAI_API_KEY')
|
('openai-prod', 'OPENAI_API_KEY')
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = ("_secret", "_env_variable")
|
__slots__ = ("_secret", "_env_variable", "_namespace_path")
|
||||||
|
|
||||||
def __init__(self, secret: str, env_variable: str):
|
def __init__(self, secret: str, env_variable: str, *, namespace_path=None):
|
||||||
self._secret = validate_secret_name(secret)
|
self._secret = validate_secret_name(secret)
|
||||||
self._env_variable = validate_env_variable(env_variable)
|
self._env_variable = validate_env_variable(env_variable)
|
||||||
|
self._namespace_path = validate_namespace_path(namespace_path)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def secret(self) -> str:
|
def secret(self) -> str:
|
||||||
@@ -92,10 +118,20 @@ class EnvVarSecret:
|
|||||||
"""The environment variable the value is delivered in."""
|
"""The environment variable the value is delivered in."""
|
||||||
return self._env_variable
|
return self._env_variable
|
||||||
|
|
||||||
|
@property
|
||||||
|
def namespace_path(self):
|
||||||
|
"""The namespace path the Secret is addressed within, root when empty."""
|
||||||
|
return list(self._namespace_path)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
|
path = (
|
||||||
|
f", namespace_path={list(self._namespace_path)!r}"
|
||||||
|
if self._namespace_path
|
||||||
|
else ""
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
f"EnvVarSecret(secret={self._secret!r}, "
|
f"EnvVarSecret(secret={self._secret!r}, "
|
||||||
f"env_variable={self._env_variable!r})"
|
f"env_variable={self._env_variable!r}{path})"
|
||||||
)
|
)
|
||||||
|
|
||||||
def __eq__(self, other: object) -> bool:
|
def __eq__(self, other: object) -> bool:
|
||||||
@@ -103,10 +139,13 @@ class EnvVarSecret:
|
|||||||
isinstance(other, EnvVarSecret)
|
isinstance(other, EnvVarSecret)
|
||||||
and other._secret == self._secret
|
and other._secret == self._secret
|
||||||
and other._env_variable == self._env_variable
|
and other._env_variable == self._env_variable
|
||||||
|
and other._namespace_path == self._namespace_path
|
||||||
)
|
)
|
||||||
|
|
||||||
def __hash__(self) -> int:
|
def __hash__(self) -> int:
|
||||||
return hash((EnvVarSecret, self._secret, self._env_variable))
|
return hash(
|
||||||
|
(EnvVarSecret, self._secret, self._env_variable, self._namespace_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SecretInfo:
|
class SecretInfo:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from lancedb.functions import (
|
|||||||
PythonRuntimeSpec,
|
PythonRuntimeSpec,
|
||||||
SecretBinding,
|
SecretBinding,
|
||||||
RefreshColumnResult,
|
RefreshColumnResult,
|
||||||
|
SecretReference,
|
||||||
)
|
)
|
||||||
from lancedb.table import AsyncTable
|
from lancedb.table import AsyncTable
|
||||||
|
|
||||||
@@ -112,7 +113,9 @@ def test_function_version_identity_is_immutable_and_exact():
|
|||||||
assert version.name == "embed"
|
assert version.name == "embed"
|
||||||
assert version.version == "fv_01K3EXACT"
|
assert version.version == "fv_01K3EXACT"
|
||||||
assert list(version.secret_bindings) == [
|
assert list(version.secret_bindings) == [
|
||||||
SecretBinding(kind="env", variable="HF_TOKEN", secret_ref="hf-prod")
|
SecretBinding(
|
||||||
|
kind="env", variable="HF_TOKEN", secret_ref=SecretReference(name="hf-prod")
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
with pytest.raises((TypeError, ValueError)):
|
with pytest.raises((TypeError, ValueError)):
|
||||||
@@ -303,7 +306,7 @@ def test_canonical_client_values_carry_bindings_and_no_credentials():
|
|||||||
)
|
)
|
||||||
canonical = json.loads(version.to_canonical_json())
|
canonical = json.loads(version.to_canonical_json())
|
||||||
assert canonical["secret_bindings"] == [
|
assert canonical["secret_bindings"] == [
|
||||||
{"kind": "env", "variable": "HF_TOKEN", "secret_ref": "hf-prod"}
|
{"kind": "env", "variable": "HF_TOKEN", "secret_ref": {"name": "hf-prod"}}
|
||||||
]
|
]
|
||||||
assert_no_secret_values(canonical)
|
assert_no_secret_values(canonical)
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import lancedb
|
|||||||
from lancedb.functions import (
|
from lancedb.functions import (
|
||||||
PythonRuntimeSpec,
|
PythonRuntimeSpec,
|
||||||
SecretBinding,
|
SecretBinding,
|
||||||
|
SecretReference,
|
||||||
UdfDefinition,
|
UdfDefinition,
|
||||||
_canonical_arrow_type,
|
_canonical_arrow_type,
|
||||||
_GRAMMAR_PRIMITIVES,
|
_GRAMMAR_PRIMITIVES,
|
||||||
@@ -94,6 +95,67 @@ def test_secret_bound_udf_matches_its_shared_registration_golden():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_namespaced_binding_records_the_path_and_the_name():
|
||||||
|
"""A binding names the parts, so nothing has to be parsed back out.
|
||||||
|
|
||||||
|
A root binding carries no path at all, which is what keeps its wire shape
|
||||||
|
identical to one written before namespaces existed.
|
||||||
|
"""
|
||||||
|
root = EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")
|
||||||
|
assert root.namespace_path == []
|
||||||
|
|
||||||
|
nested = EnvVarSecret(
|
||||||
|
secret="openai-prod",
|
||||||
|
env_variable="OPENAI_API_KEY",
|
||||||
|
namespace_path=["prod", "vision"],
|
||||||
|
)
|
||||||
|
assert nested.namespace_path == ["prod", "vision"]
|
||||||
|
assert nested != root
|
||||||
|
|
||||||
|
bound = analyze_caption.bind_secrets([nested])
|
||||||
|
assert list(bound.secret_bindings) == [
|
||||||
|
SecretBinding(
|
||||||
|
kind="env",
|
||||||
|
variable="OPENAI_API_KEY",
|
||||||
|
secret_ref=SecretReference(
|
||||||
|
name="openai-prod", namespace_path=("prod", "vision")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
at_root = analyze_caption.bind_secrets([root])
|
||||||
|
assert list(at_root.secret_bindings) == [
|
||||||
|
SecretBinding(
|
||||||
|
kind="env",
|
||||||
|
variable="OPENAI_API_KEY",
|
||||||
|
secret_ref=SecretReference(name="openai-prod"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
# A root binding carries no path at all on the wire.
|
||||||
|
canonical = json.loads(at_root.to_canonical_json())
|
||||||
|
assert canonical["secret_bindings"] == [
|
||||||
|
{
|
||||||
|
"kind": "env",
|
||||||
|
"variable": "OPENAI_API_KEY",
|
||||||
|
"secret_ref": {"name": "openai-prod"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_namespace_path_is_validated_locally():
|
||||||
|
# The charset is the service's, not a delimiter's: a reference is never
|
||||||
|
# joined, so a segment cannot make anything parse two ways.
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
EnvVarSecret(
|
||||||
|
secret="openai-prod", env_variable="K", namespace_path=["with$delim"]
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
EnvVarSecret(secret="openai-prod", env_variable="K", namespace_path=["a/b"])
|
||||||
|
# A bare string is a plausible mistake with the wrong meaning.
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
EnvVarSecret(secret="openai-prod", env_variable="K", namespace_path="prod")
|
||||||
|
|
||||||
|
|
||||||
def test_an_unbound_request_carries_no_binding_at_all():
|
def test_an_unbound_request_carries_no_binding_at_all():
|
||||||
"""Binding is a registration-time decision, so the definition holds none.
|
"""Binding is a registration-time decision, so the definition holds none.
|
||||||
|
|
||||||
@@ -168,9 +230,11 @@ def test_a_binding_envelope_reaches_the_service_for_it_to_judge():
|
|||||||
|
|
||||||
sent = state["requests"][0][1]
|
sent = state["requests"][0][1]
|
||||||
assert len(sent["secret_bindings"]) == 17
|
assert len(sent["secret_bindings"]) == 17
|
||||||
assert {"kind": "env", "variable": "TOKEN_0", "secret_ref": "secret-0"} in sent[
|
assert {
|
||||||
"secret_bindings"
|
"kind": "env",
|
||||||
]
|
"variable": "TOKEN_0",
|
||||||
|
"secret_ref": {"name": "secret-0"},
|
||||||
|
} in sent["secret_bindings"]
|
||||||
|
|
||||||
|
|
||||||
_SECRET_DEBUG_LOG_SOURCE = """
|
_SECRET_DEBUG_LOG_SOURCE = """
|
||||||
@@ -271,6 +335,24 @@ def test_a_secret_name_admits_what_a_namespace_name_does():
|
|||||||
with pytest.raises(ValueError, match="invalid Secret name"):
|
with pytest.raises(ValueError, match="invalid Secret name"):
|
||||||
EnvVarSecret(secret=name, env_variable="OPENAI_API_KEY")
|
EnvVarSecret(secret=name, env_variable="OPENAI_API_KEY")
|
||||||
|
|
||||||
|
# A namespace segment follows the same rule, and LanceDB already admits
|
||||||
|
# these shapes as namespace names -- so a Secret is addressable inside one.
|
||||||
|
for segment in [".hidden", "_internal", "-lead", "trailing."]:
|
||||||
|
binding = EnvVarSecret(
|
||||||
|
secret="openai-prod",
|
||||||
|
env_variable="OPENAI_API_KEY",
|
||||||
|
namespace_path=[segment],
|
||||||
|
)
|
||||||
|
assert binding.namespace_path == [segment]
|
||||||
|
|
||||||
|
for segment in ["", "with/slash", "with$delimiter"]:
|
||||||
|
with pytest.raises(ValueError, match="invalid namespace path segment"):
|
||||||
|
EnvVarSecret(
|
||||||
|
secret="openai-prod",
|
||||||
|
env_variable="OPENAI_API_KEY",
|
||||||
|
namespace_path=[segment],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _main_udf_source(
|
def _main_udf_source(
|
||||||
*, threshold: int = 20, input_annotation: str = "int", comparison: str = ">="
|
*, threshold: int = 20, input_annotation: str = "int", comparison: str = ">="
|
||||||
@@ -1545,12 +1627,20 @@ def test_remote_registration_sends_bindings_and_never_a_credential():
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert list(created.secret_bindings) == [
|
assert list(created.secret_bindings) == [
|
||||||
SecretBinding(kind="env", variable="OPENAI_API_KEY", secret_ref="openai-prod")
|
SecretBinding(
|
||||||
|
kind="env",
|
||||||
|
variable="OPENAI_API_KEY",
|
||||||
|
secret_ref=SecretReference(name="openai-prod"),
|
||||||
|
)
|
||||||
]
|
]
|
||||||
path, create_request = state["requests"][0]
|
path, create_request = state["requests"][0]
|
||||||
assert path == "/v1/functions/create"
|
assert path == "/v1/functions/create"
|
||||||
assert create_request["secret_bindings"] == [
|
assert create_request["secret_bindings"] == [
|
||||||
{"kind": "env", "variable": "OPENAI_API_KEY", "secret_ref": "openai-prod"}
|
{
|
||||||
|
"kind": "env",
|
||||||
|
"variable": "OPENAI_API_KEY",
|
||||||
|
"secret_ref": {"name": "openai-prod"},
|
||||||
|
}
|
||||||
]
|
]
|
||||||
# The request names a Secret and carries nothing that could be one.
|
# The request names a Secret and carries nothing that could be one.
|
||||||
assert create_request == json.loads(
|
assert create_request == json.loads(
|
||||||
|
|||||||
@@ -708,10 +708,14 @@ impl Connection {
|
|||||||
self_: PyRef<'_, Self>,
|
self_: PyRef<'_, Self>,
|
||||||
name: String,
|
name: String,
|
||||||
value: String,
|
value: String,
|
||||||
|
namespace_path: Vec<String>,
|
||||||
) -> PyResult<Bound<'_, PyAny>> {
|
) -> PyResult<Bound<'_, PyAny>> {
|
||||||
let inner = self_.get_inner()?.clone();
|
let inner = self_.get_inner()?.clone();
|
||||||
future_into_py(self_.py(), async move {
|
future_into_py(self_.py(), async move {
|
||||||
inner.create_secret(name, value).await.infer_error()
|
inner
|
||||||
|
.create_secret(name, value, &namespace_path)
|
||||||
|
.await
|
||||||
|
.infer_error()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,34 +723,52 @@ impl Connection {
|
|||||||
self_: PyRef<'_, Self>,
|
self_: PyRef<'_, Self>,
|
||||||
name: String,
|
name: String,
|
||||||
value: String,
|
value: String,
|
||||||
|
namespace_path: Vec<String>,
|
||||||
) -> PyResult<Bound<'_, PyAny>> {
|
) -> PyResult<Bound<'_, PyAny>> {
|
||||||
let inner = self_.get_inner()?.clone();
|
let inner = self_.get_inner()?.clone();
|
||||||
future_into_py(self_.py(), async move {
|
future_into_py(self_.py(), async move {
|
||||||
inner.alter_secret(name, value).await.infer_error()
|
inner
|
||||||
|
.alter_secret(name, value, &namespace_path)
|
||||||
|
.await
|
||||||
|
.infer_error()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_secrets(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
|
pub fn list_secrets(
|
||||||
|
self_: PyRef<'_, Self>,
|
||||||
|
namespace_path: Vec<String>,
|
||||||
|
) -> PyResult<Bound<'_, PyAny>> {
|
||||||
let inner = self_.get_inner()?.clone();
|
let inner = self_.get_inner()?.clone();
|
||||||
future_into_py(self_.py(), async move {
|
future_into_py(self_.py(), async move {
|
||||||
inner.list_secrets().await.infer_error()
|
inner.list_secrets(&namespace_path).await.infer_error()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn drop_secret(self_: PyRef<'_, Self>, name: String) -> PyResult<Bound<'_, PyAny>> {
|
pub fn drop_secret(
|
||||||
|
self_: PyRef<'_, Self>,
|
||||||
|
name: String,
|
||||||
|
namespace_path: Vec<String>,
|
||||||
|
) -> PyResult<Bound<'_, PyAny>> {
|
||||||
let inner = self_.get_inner()?.clone();
|
let inner = self_.get_inner()?.clone();
|
||||||
future_into_py(self_.py(), async move {
|
future_into_py(self_.py(), async move {
|
||||||
inner.drop_secret(name).await.infer_error()
|
inner.drop_secret(name, &namespace_path).await.infer_error()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Name and timestamps as a plain tuple. `SecretInfo` carries no value, so
|
/// Name and timestamps as a plain tuple. `SecretInfo` carries no value, so
|
||||||
/// there is none to filter out here. Timestamps stay integers rather than
|
/// there is none to filter out here. Timestamps stay integers rather than
|
||||||
/// going through a string, so the caller can compare two without parsing.
|
/// going through a string, so the caller can compare two without parsing.
|
||||||
pub fn describe_secret(self_: PyRef<'_, Self>, name: String) -> PyResult<Bound<'_, PyAny>> {
|
pub fn describe_secret(
|
||||||
|
self_: PyRef<'_, Self>,
|
||||||
|
name: String,
|
||||||
|
namespace_path: Vec<String>,
|
||||||
|
) -> PyResult<Bound<'_, PyAny>> {
|
||||||
let inner = self_.get_inner()?.clone();
|
let inner = self_.get_inner()?.clone();
|
||||||
future_into_py(self_.py(), async move {
|
future_into_py(self_.py(), async move {
|
||||||
let info = inner.describe_secret(name).await.infer_error()?;
|
let info = inner
|
||||||
|
.describe_secret(name, &namespace_path)
|
||||||
|
.await
|
||||||
|
.infer_error()?;
|
||||||
Ok((info.name, info.created_at_millis, info.updated_at_millis))
|
Ok((info.name, info.created_at_millis, info.updated_at_millis))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -653,9 +653,14 @@ impl Connection {
|
|||||||
/// rotation. There is no API that reads a stored credential back; the only
|
/// rotation. There is no API that reads a stored credential back; the only
|
||||||
/// consumer is a Function that binds the Secret by name. Local databases
|
/// consumer is a Function that binds the Secret by name. Local databases
|
||||||
/// return [`Error::NotSupported`].
|
/// return [`Error::NotSupported`].
|
||||||
pub async fn create_secret(&self, name: impl AsRef<str>, value: impl AsRef<str>) -> Result<()> {
|
pub async fn create_secret(
|
||||||
|
&self,
|
||||||
|
name: impl AsRef<str>,
|
||||||
|
value: impl AsRef<str>,
|
||||||
|
namespace_path: &[String],
|
||||||
|
) -> Result<()> {
|
||||||
self.internal
|
self.internal
|
||||||
.create_secret(name.as_ref(), value.as_ref())
|
.create_secret(name.as_ref(), value.as_ref(), namespace_path)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,9 +671,14 @@ impl Connection {
|
|||||||
/// minted -- which is what lets a rotation reach columns pinned to a
|
/// minted -- which is what lets a rotation reach columns pinned to a
|
||||||
/// version registered before it. Local databases return
|
/// version registered before it. Local databases return
|
||||||
/// [`Error::NotSupported`].
|
/// [`Error::NotSupported`].
|
||||||
pub async fn alter_secret(&self, name: impl AsRef<str>, value: impl AsRef<str>) -> Result<()> {
|
pub async fn alter_secret(
|
||||||
|
&self,
|
||||||
|
name: impl AsRef<str>,
|
||||||
|
value: impl AsRef<str>,
|
||||||
|
namespace_path: &[String],
|
||||||
|
) -> Result<()> {
|
||||||
self.internal
|
self.internal
|
||||||
.alter_secret(name.as_ref(), value.as_ref())
|
.alter_secret(name.as_ref(), value.as_ref(), namespace_path)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,8 +687,8 @@ impl Connection {
|
|||||||
/// Names only. No path in this API returns a stored credential, by
|
/// Names only. No path in this API returns a stored credential, by
|
||||||
/// construction rather than by policy. Local databases return
|
/// construction rather than by policy. Local databases return
|
||||||
/// [`Error::NotSupported`].
|
/// [`Error::NotSupported`].
|
||||||
pub async fn list_secrets(&self) -> Result<Vec<String>> {
|
pub async fn list_secrets(&self, namespace_path: &[String]) -> Result<Vec<String>> {
|
||||||
self.internal.list_secrets().await
|
self.internal.list_secrets(namespace_path).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop a Secret.
|
/// Drop a Secret.
|
||||||
@@ -687,8 +697,14 @@ impl Connection {
|
|||||||
/// is the revocation path. The name becomes free to reuse, and a new
|
/// is the revocation path. The name becomes free to reuse, and a new
|
||||||
/// Secret under it is picked up by everything still bound to that name.
|
/// Secret under it is picked up by everything still bound to that name.
|
||||||
/// Local databases return [`Error::NotSupported`].
|
/// Local databases return [`Error::NotSupported`].
|
||||||
pub async fn drop_secret(&self, name: impl AsRef<str>) -> Result<()> {
|
pub async fn drop_secret(
|
||||||
self.internal.drop_secret(name.as_ref()).await
|
&self,
|
||||||
|
name: impl AsRef<str>,
|
||||||
|
namespace_path: &[String],
|
||||||
|
) -> Result<()> {
|
||||||
|
self.internal
|
||||||
|
.drop_secret(name.as_ref(), namespace_path)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What this database records about one Secret: its name and timestamps.
|
/// What this database records about one Secret: its name and timestamps.
|
||||||
@@ -696,8 +712,14 @@ impl Connection {
|
|||||||
/// Never the value. The type it returns has no field for one, so this is a
|
/// Never the value. The type it returns has no field for one, so this is a
|
||||||
/// property of the API rather than of what the caller chooses to read.
|
/// property of the API rather than of what the caller chooses to read.
|
||||||
/// Local databases return [`Error::NotSupported`].
|
/// Local databases return [`Error::NotSupported`].
|
||||||
pub async fn describe_secret(&self, name: impl AsRef<str>) -> Result<SecretInfo> {
|
pub async fn describe_secret(
|
||||||
self.internal.describe_secret(name.as_ref()).await
|
&self,
|
||||||
|
name: impl AsRef<str>,
|
||||||
|
namespace_path: &[String],
|
||||||
|
) -> Result<SecretInfo> {
|
||||||
|
self.internal
|
||||||
|
.describe_secret(name.as_ref(), namespace_path)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rename a table in the database.
|
/// Rename a table in the database.
|
||||||
|
|||||||
@@ -326,30 +326,40 @@ pub trait Database:
|
|||||||
}
|
}
|
||||||
/// Create a named Secret in this database. Fails if the name is taken, so
|
/// Create a named Secret in this database. Fails if the name is taken, so
|
||||||
/// a create can never silently become a rotation.
|
/// a create can never silently become a rotation.
|
||||||
async fn create_secret(&self, _name: &str, _value: &str) -> Result<()> {
|
async fn create_secret(
|
||||||
|
&self,
|
||||||
|
_name: &str,
|
||||||
|
_value: &str,
|
||||||
|
_namespace_path: &[String],
|
||||||
|
) -> Result<()> {
|
||||||
secret_catalog_not_supported()
|
secret_catalog_not_supported()
|
||||||
}
|
}
|
||||||
/// Replace the credential behind an existing Secret. Fails if it does not
|
/// Replace the credential behind an existing Secret. Fails if it does not
|
||||||
/// exist. Every Function bound to it resolves the new value from its next
|
/// exist. Every Function bound to it resolves the new value from its next
|
||||||
/// execution, with no new Function version.
|
/// execution, with no new Function version.
|
||||||
async fn alter_secret(&self, _name: &str, _value: &str) -> Result<()> {
|
async fn alter_secret(
|
||||||
|
&self,
|
||||||
|
_name: &str,
|
||||||
|
_value: &str,
|
||||||
|
_namespace_path: &[String],
|
||||||
|
) -> Result<()> {
|
||||||
secret_catalog_not_supported()
|
secret_catalog_not_supported()
|
||||||
}
|
}
|
||||||
/// The names of every Secret in this database.
|
/// The names of every Secret in this database.
|
||||||
///
|
///
|
||||||
/// Names only. No API path returns a stored credential, by construction
|
/// Names only. No API path returns a stored credential, by construction
|
||||||
/// rather than by policy.
|
/// rather than by policy.
|
||||||
async fn list_secrets(&self) -> Result<Vec<String>> {
|
async fn list_secrets(&self, _namespace_path: &[String]) -> Result<Vec<String>> {
|
||||||
secret_catalog_not_supported()
|
secret_catalog_not_supported()
|
||||||
}
|
}
|
||||||
/// Drop a Secret. Functions bound to it fail at their next job, which is
|
/// Drop a Secret. Functions bound to it fail at their next job, which is
|
||||||
/// the revocation path.
|
/// the revocation path.
|
||||||
async fn drop_secret(&self, _name: &str) -> Result<()> {
|
async fn drop_secret(&self, _name: &str, _namespace_path: &[String]) -> Result<()> {
|
||||||
secret_catalog_not_supported()
|
secret_catalog_not_supported()
|
||||||
}
|
}
|
||||||
/// What the database records about one Secret: its name and timestamps,
|
/// What the database records about one Secret: its name and timestamps,
|
||||||
/// never its value.
|
/// never its value.
|
||||||
async fn describe_secret(&self, _name: &str) -> Result<SecretInfo> {
|
async fn describe_secret(&self, _name: &str, _namespace_path: &[String]) -> Result<SecretInfo> {
|
||||||
secret_catalog_not_supported()
|
secret_catalog_not_supported()
|
||||||
}
|
}
|
||||||
/// Open a job by id, returning a handle with its record already
|
/// Open a job by id, returning a handle with its record already
|
||||||
|
|||||||
@@ -493,6 +493,40 @@ pub struct FunctionArtifactRequest {
|
|||||||
pub adapter: PythonAdapterSpec,
|
pub adapter: PythonAdapterSpec,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where a Secret lives, carried as its parts rather than as one string.
|
||||||
|
///
|
||||||
|
/// A joined id would need a delimiter, and a delimiter has to be excluded from
|
||||||
|
/// every name and segment forever, agreed on by both sides, and re-agreed each
|
||||||
|
/// time either grows a new way to be configured. Naming the parts costs one
|
||||||
|
/// object and settles all of that: nothing here is parsed, so nothing can parse
|
||||||
|
/// two ways.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
pub struct SecretReference {
|
||||||
|
pub name: String,
|
||||||
|
/// The namespace holding the Secret. Empty is the root, and is omitted from
|
||||||
|
/// the wire so a root binding carries no trace of a feature it does not use.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub namespace_path: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SecretReference {
|
||||||
|
/// A Secret in the root namespace.
|
||||||
|
pub fn new(name: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
namespace_path: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Secret in `namespace_path`.
|
||||||
|
pub fn in_namespace(name: impl Into<String>, namespace_path: Vec<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
namespace_path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// How a Secret reaches the Function that binds it.
|
/// How a Secret reaches the Function that binds it.
|
||||||
///
|
///
|
||||||
/// One list rather than a field per delivery mode: a binding is the concept,
|
/// One list rather than a field per delivery mode: a binding is the concept,
|
||||||
@@ -513,7 +547,7 @@ pub enum SecretBinding {
|
|||||||
/// Named `secret_ref` rather than `secret` because a Job payload is
|
/// Named `secret_ref` rather than `secret` because a Job payload is
|
||||||
/// scanned server-side for credential-shaped keys, and a key called
|
/// scanned server-side for credential-shaped keys, and a key called
|
||||||
/// `secret` trips that guard whatever it actually holds.
|
/// `secret` trips that guard whatever it actually holds.
|
||||||
secret_ref: String,
|
secret_ref: SecretReference,
|
||||||
},
|
},
|
||||||
/// A binding kind introduced by a newer server.
|
/// A binding kind introduced by a newer server.
|
||||||
Unrecognized { kind: String },
|
Unrecognized { kind: String },
|
||||||
@@ -538,7 +572,7 @@ impl SecretBinding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The Secret bound, or `None` for a kind this client cannot read.
|
/// The Secret bound, or `None` for a kind this client cannot read.
|
||||||
pub fn secret(&self) -> Option<&str> {
|
pub fn secret(&self) -> Option<&SecretReference> {
|
||||||
match self {
|
match self {
|
||||||
Self::Env { secret_ref, .. } => Some(secret_ref),
|
Self::Env { secret_ref, .. } => Some(secret_ref),
|
||||||
Self::Unrecognized { .. } => None,
|
Self::Unrecognized { .. } => None,
|
||||||
@@ -549,7 +583,7 @@ impl SecretBinding {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct EnvSecretBindingWire {
|
struct EnvSecretBindingWire {
|
||||||
variable: String,
|
variable: String,
|
||||||
secret_ref: String,
|
secret_ref: SecretReference,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'de> Deserialize<'de> for SecretBinding {
|
impl<'de> Deserialize<'de> for SecretBinding {
|
||||||
@@ -581,7 +615,7 @@ impl Serialize for SecretBinding {
|
|||||||
struct EnvBindingRef<'a> {
|
struct EnvBindingRef<'a> {
|
||||||
kind: &'static str,
|
kind: &'static str,
|
||||||
variable: &'a str,
|
variable: &'a str,
|
||||||
secret_ref: &'a str,
|
secret_ref: &'a SecretReference,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|||||||
@@ -278,6 +278,22 @@ pub struct RemoteHostOverrides {
|
|||||||
pub sql: Option<String>,
|
pub sql: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attach a namespace path to a Secret request body.
|
||||||
|
///
|
||||||
|
/// A root path is omitted rather than sent empty, so a root request is byte
|
||||||
|
/// identical to one from a client that predates namespace addressing.
|
||||||
|
fn add_namespace_path(body: &mut serde_json::Value, namespace_path: &[String]) {
|
||||||
|
if namespace_path.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body["namespace_path"] = serde_json::Value::Array(
|
||||||
|
namespace_path
|
||||||
|
.iter()
|
||||||
|
.map(|segment| serde_json::Value::String(segment.clone()))
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
impl RemoteDatabase {
|
impl RemoteDatabase {
|
||||||
pub(crate) fn try_new(
|
pub(crate) fn try_new(
|
||||||
uri: &str,
|
uri: &str,
|
||||||
@@ -599,6 +615,10 @@ struct RemoteDropFunctionResponse {
|
|||||||
struct RemoteCreateSecretRequest<'a> {
|
struct RemoteCreateSecretRequest<'a> {
|
||||||
name: &'a str,
|
name: &'a str,
|
||||||
value: &'a str,
|
value: &'a str,
|
||||||
|
/// Omitted at the root, so a request from a client that predates namespaces
|
||||||
|
/// is byte-identical to one that does not use them.
|
||||||
|
#[serde(skip_serializing_if = "<[String]>::is_empty")]
|
||||||
|
namespace_path: &'a [String],
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace the credential behind a Secret the database already holds.
|
/// Replace the credential behind a Secret the database already holds.
|
||||||
@@ -606,15 +626,21 @@ struct RemoteCreateSecretRequest<'a> {
|
|||||||
struct RemoteAlterSecretRequest<'a> {
|
struct RemoteAlterSecretRequest<'a> {
|
||||||
name: &'a str,
|
name: &'a str,
|
||||||
value: &'a str,
|
value: &'a str,
|
||||||
|
#[serde(skip_serializing_if = "<[String]>::is_empty")]
|
||||||
|
namespace_path: &'a [String],
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One page of a Secret listing. A struct rather than an inline object so the
|
/// One page of a Secret listing. A struct rather than an inline object so the
|
||||||
/// request and the response are declared the same way -- a reader of one finds
|
/// request and the response are declared the same way -- a reader of one finds
|
||||||
/// the other.
|
/// the other.
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
struct RemoteListSecretsRequest {
|
struct RemoteListSecretsRequest<'a> {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
page_token: Option<String>,
|
page_token: Option<String>,
|
||||||
|
/// Omitted at the root, so a listing from a client that predates namespaces
|
||||||
|
/// is byte-identical to one that does not use them.
|
||||||
|
#[serde(skip_serializing_if = "<[String]>::is_empty")]
|
||||||
|
namespace_path: &'a [String],
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
@@ -733,29 +759,43 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
|
|||||||
Ok(response.dropped)
|
Ok(response.dropped)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_secret(&self, name: &str, value: &str) -> Result<()> {
|
async fn create_secret(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
value: &str,
|
||||||
|
namespace_path: &[String],
|
||||||
|
) -> Result<()> {
|
||||||
self.post_secret_write(
|
self.post_secret_write(
|
||||||
"/v1/secrets/create",
|
"/v1/secrets/create",
|
||||||
&RemoteCreateSecretRequest { name, value },
|
&RemoteCreateSecretRequest {
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
namespace_path,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn alter_secret(&self, name: &str, value: &str) -> Result<()> {
|
async fn alter_secret(&self, name: &str, value: &str, namespace_path: &[String]) -> Result<()> {
|
||||||
self.post_secret_write(
|
self.post_secret_write(
|
||||||
"/v1/secrets/alter",
|
"/v1/secrets/alter",
|
||||||
&RemoteAlterSecretRequest { name, value },
|
&RemoteAlterSecretRequest {
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
namespace_path,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_secrets(&self) -> Result<Vec<String>> {
|
async fn list_secrets(&self, namespace_path: &[String]) -> Result<Vec<String>> {
|
||||||
let mut names = Vec::new();
|
let mut names = Vec::new();
|
||||||
let mut page_token: Option<String> = None;
|
let mut page_token: Option<String> = None;
|
||||||
let mut seen_page_tokens = HashSet::new();
|
let mut seen_page_tokens = HashSet::new();
|
||||||
loop {
|
loop {
|
||||||
let body = RemoteListSecretsRequest {
|
let body = RemoteListSecretsRequest {
|
||||||
page_token: page_token.clone(),
|
page_token: page_token.clone(),
|
||||||
|
namespace_path,
|
||||||
};
|
};
|
||||||
let req = self.client.post("/v1/secrets/list").json(&body);
|
let req = self.client.post("/v1/secrets/list").json(&body);
|
||||||
let (request_id, response) = self.client.send(req).await?;
|
let (request_id, response) = self.client.send(req).await?;
|
||||||
@@ -780,21 +820,19 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
|
|||||||
Ok(names)
|
Ok(names)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn drop_secret(&self, name: &str) -> Result<()> {
|
async fn drop_secret(&self, name: &str, namespace_path: &[String]) -> Result<()> {
|
||||||
let req = self
|
let mut body = serde_json::json!({ "name": name });
|
||||||
.client
|
add_namespace_path(&mut body, namespace_path);
|
||||||
.post("/v1/secrets/drop")
|
let req = self.client.post("/v1/secrets/drop").json(&body);
|
||||||
.json(&serde_json::json!({ "name": name }));
|
|
||||||
let (request_id, response) = self.client.send(req).await?;
|
let (request_id, response) = self.client.send(req).await?;
|
||||||
self.client.check_response(&request_id, response).await?;
|
self.client.check_response(&request_id, response).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn describe_secret(&self, name: &str) -> Result<SecretInfo> {
|
async fn describe_secret(&self, name: &str, namespace_path: &[String]) -> Result<SecretInfo> {
|
||||||
let req = self
|
let mut body = serde_json::json!({ "name": name });
|
||||||
.client
|
add_namespace_path(&mut body, namespace_path);
|
||||||
.post("/v1/secrets/describe")
|
let req = self.client.post("/v1/secrets/describe").json(&body);
|
||||||
.json(&serde_json::json!({ "name": name }));
|
|
||||||
let (request_id, response) = self.client.send(req).await?;
|
let (request_id, response) = self.client.send(req).await?;
|
||||||
let response = self.client.check_response(&request_id, response).await?;
|
let response = self.client.check_response(&request_id, response).await?;
|
||||||
response.json().await.err_to_http(request_id)
|
response.json().await.err_to_http(request_id)
|
||||||
@@ -2926,11 +2964,11 @@ mod tests {
|
|||||||
http::Response::builder().status(200).body("{}").unwrap()
|
http::Response::builder().status(200).body("{}").unwrap()
|
||||||
});
|
});
|
||||||
if call {
|
if call {
|
||||||
conn.create_secret("openai-prod", "sk-live-0001")
|
conn.create_secret("openai-prod", "sk-live-0001", &[])
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
} else {
|
} else {
|
||||||
conn.alter_secret("openai-prod", "sk-live-0001")
|
conn.alter_secret("openai-prod", "sk-live-0001", &[])
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
@@ -2952,7 +2990,7 @@ mod tests {
|
|||||||
http::Response::builder().status(200).body(body).unwrap()
|
http::Response::builder().status(200).body(body).unwrap()
|
||||||
});
|
});
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
conn.list_secrets().await.unwrap(),
|
conn.list_secrets(&[]).await.unwrap(),
|
||||||
vec!["openai-prod".to_string(), "hf-prod".to_string()]
|
vec!["openai-prod".to_string(), "hf-prod".to_string()]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2967,7 +3005,7 @@ mod tests {
|
|||||||
.body(r#"{"secrets":[{"name":"openai-prod"}],"page_token":"same"}"#)
|
.body(r#"{"secrets":[{"name":"openai-prod"}],"page_token":"same"}"#)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
});
|
});
|
||||||
let error = conn.list_secrets().await.unwrap_err();
|
let error = conn.list_secrets(&[]).await.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
error.to_string().contains("repeated a page_token"),
|
error.to_string().contains("repeated a page_token"),
|
||||||
"{error}"
|
"{error}"
|
||||||
@@ -2983,7 +3021,38 @@ mod tests {
|
|||||||
assert_eq!(body, serde_json::json!({"name": "openai-prod"}));
|
assert_eq!(body, serde_json::json!({"name": "openai-prod"}));
|
||||||
http::Response::builder().status(200).body("{}").unwrap()
|
http::Response::builder().status(200).body("{}").unwrap()
|
||||||
});
|
});
|
||||||
conn.drop_secret("openai-prod").await.unwrap();
|
conn.drop_secret("openai-prod", &[]).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A namespace path is sent when there is one and omitted when there is
|
||||||
|
/// not, so a root request stays byte identical to one from a client that
|
||||||
|
/// predates namespace addressing -- which is what lets the parameter ship
|
||||||
|
/// before every server implements it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_a_namespace_path_is_sent_only_when_it_is_not_root() {
|
||||||
|
let conn = Connection::new_with_handler(|request| {
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
body,
|
||||||
|
serde_json::json!({
|
||||||
|
"name": "openai-prod",
|
||||||
|
"namespace_path": ["prod", "vision"],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
http::Response::builder().status(200).body("{}").unwrap()
|
||||||
|
});
|
||||||
|
conn.drop_secret("openai-prod", &["prod".to_string(), "vision".to_string()])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let conn = Connection::new_with_handler(|request| {
|
||||||
|
let body: serde_json::Value =
|
||||||
|
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap()).unwrap();
|
||||||
|
assert!(body.get("namespace_path").is_none(), "{body}");
|
||||||
|
http::Response::builder().status(200).body("{}").unwrap()
|
||||||
|
});
|
||||||
|
conn.drop_secret("openai-prod", &[]).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use lancedb::function::{
|
use lancedb::function::{
|
||||||
FunctionApplication, FunctionBinding, FunctionVersion, RefreshColumnResult, SecretBinding,
|
FunctionApplication, FunctionBinding, FunctionVersion, RefreshColumnResult, SecretBinding,
|
||||||
|
SecretReference,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ fn function_version_job_result_matches_shared_canonical_golden() {
|
|||||||
version.secret_bindings(),
|
version.secret_bindings(),
|
||||||
[SecretBinding::Env {
|
[SecretBinding::Env {
|
||||||
variable: "HF_TOKEN".to_string(),
|
variable: "HF_TOKEN".to_string(),
|
||||||
secret_ref: "hf-prod".to_string(),
|
secret_ref: SecretReference::new("hf-prod"),
|
||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -183,7 +184,7 @@ fn canonical_client_values_carry_bindings_and_no_credentials() {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
canonical["secret_bindings"],
|
canonical["secret_bindings"],
|
||||||
serde_json::json!([{"kind": "env", "variable": "HF_TOKEN", "secret_ref": "hf-prod"}])
|
serde_json::json!([{"kind": "env", "variable": "HF_TOKEN", "secret_ref": {"name": "hf-prod"}}])
|
||||||
);
|
);
|
||||||
assert_no_secret_values(&canonical);
|
assert_no_secret_values(&canonical);
|
||||||
}
|
}
|
||||||
@@ -198,8 +199,8 @@ fn canonical_client_values_carry_bindings_and_no_credentials() {
|
|||||||
fn an_unknown_binding_kind_is_forward_decodable() {
|
fn an_unknown_binding_kind_is_forward_decodable() {
|
||||||
let mut result = job_result("remote_function_job.json");
|
let mut result = job_result("remote_function_job.json");
|
||||||
result["secret_bindings"] = serde_json::json!([
|
result["secret_bindings"] = serde_json::json!([
|
||||||
{"kind": "env", "variable": "HF_TOKEN", "secret_ref": "hf-prod"},
|
{"kind": "env", "variable": "HF_TOKEN", "secret_ref": {"name": "hf-prod"}},
|
||||||
{"kind": "file", "path": "/run/secrets/tok", "secret_ref": "hf-prod"},
|
{"kind": "file", "path": "/run/secrets/tok", "secret_ref": {"name": "hf-prod"}},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let version = FunctionVersion::from_json(&result.to_string()).expect("future binding kind");
|
let version = FunctionVersion::from_json(&result.to_string()).expect("future binding kind");
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::fs;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use lancedb::Error;
|
use lancedb::Error;
|
||||||
use lancedb::function::{FunctionRegistrationRequest, SecretBinding};
|
use lancedb::function::{FunctionRegistrationRequest, SecretBinding, SecretReference};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
fn fixture(name: &str) -> String {
|
fn fixture(name: &str) -> String {
|
||||||
@@ -70,7 +70,7 @@ fn secret_bound_registration_request_matches_shared_canonical_golden() {
|
|||||||
request.secret_bindings,
|
request.secret_bindings,
|
||||||
[SecretBinding::Env {
|
[SecretBinding::Env {
|
||||||
variable: "OPENAI_API_KEY".to_string(),
|
variable: "OPENAI_API_KEY".to_string(),
|
||||||
secret_ref: "openai-prod".to_string(),
|
secret_ref: SecretReference::new("openai-prod"),
|
||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
+3
-1
@@ -48,7 +48,9 @@
|
|||||||
{
|
{
|
||||||
"kind": "env",
|
"kind": "env",
|
||||||
"variable": "HF_TOKEN",
|
"variable": "HF_TOKEN",
|
||||||
"secret_ref": "hf-prod"
|
"secret_ref": {
|
||||||
|
"name": "hf-prod"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"artifact":{"adapter":{"kind":"scalar_to_arrow_batch","version":1},"content":{"data":"ZnJvbSBfX2Z1dHVyZV9fIGltcG9ydCBhbm5vdGF0aW9ucwoKZGVmIGFuYWx5emVfY2FwdGlvbihjYXB0aW9uOiBzdHIpIC0+IHN0cjoKICAgIHJldHVybiBjYXB0aW9uLnN0cmlwKCkK","encoding":"base64"},"digest":"sha256:800462c9ad15151a80f83f85b8912ff149300c1563e07f58448f099afcd0d077","entrypoint":"analyze_caption","kind":"python_callable"},"name":"analyze_caption","runtime":{"env":{"MODE":"test"},"environment":{"kind":"pip","packages":["openai==3.7.0"]},"kind":"python","python_version":"3.12"},"secret_bindings":[{"kind":"env","secret_ref":"openai-prod","variable":"OPENAI_API_KEY"}],"signature":{"inputs":[{"arrow_type":"utf8","name":"caption","nullable":false}],"output":{"arrow_type":"utf8","kind":"scalar","nullable":false}}}
|
{"artifact":{"adapter":{"kind":"scalar_to_arrow_batch","version":1},"content":{"data":"ZnJvbSBfX2Z1dHVyZV9fIGltcG9ydCBhbm5vdGF0aW9ucwoKZGVmIGFuYWx5emVfY2FwdGlvbihjYXB0aW9uOiBzdHIpIC0+IHN0cjoKICAgIHJldHVybiBjYXB0aW9uLnN0cmlwKCkK","encoding":"base64"},"digest":"sha256:800462c9ad15151a80f83f85b8912ff149300c1563e07f58448f099afcd0d077","entrypoint":"analyze_caption","kind":"python_callable"},"name":"analyze_caption","runtime":{"env":{"MODE":"test"},"environment":{"kind":"pip","packages":["openai==3.7.0"]},"kind":"python","python_version":"3.12"},"secret_bindings":[{"kind":"env","secret_ref":{"name":"openai-prod"},"variable":"OPENAI_API_KEY"}],"signature":{"inputs":[{"arrow_type":"utf8","name":"caption","nullable":false}],"output":{"arrow_type":"utf8","kind":"scalar","nullable":false}}}
|
||||||
|
|||||||
+3
-1
@@ -44,7 +44,9 @@
|
|||||||
{
|
{
|
||||||
"kind": "env",
|
"kind": "env",
|
||||||
"variable": "OPENAI_API_KEY",
|
"variable": "OPENAI_API_KEY",
|
||||||
"secret_ref": "openai-prod"
|
"secret_ref": {
|
||||||
|
"name": "openai-prod"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1 +1 @@
|
|||||||
{"artifact":{"digest":"sha256:code","entrypoint":"embed","kind":"python_callable"},"created_at":"2026-08-21T00:00:00Z","environment_digest":"sha256:environment","name":"embed","runtime":{"env":{"TOKENIZERS_PARALLELISM":"false"},"environment":{"kind":"pip","packages":["sentence-transformers>=3"]},"kind":"python","python_version":"3.12"},"runtime_digest":"sha256:runtime","secret_bindings":[{"kind":"env","secret_ref":"hf-prod","variable":"HF_TOKEN"}],"signature":{"inputs":[{"arrow_type":"utf8","name":"text","nullable":true}],"output":{"arrow_type":"list<float32>","kind":"scalar","nullable":false}},"version":"fv_01K3EXACT"}
|
{"artifact":{"digest":"sha256:code","entrypoint":"embed","kind":"python_callable"},"created_at":"2026-08-21T00:00:00Z","environment_digest":"sha256:environment","name":"embed","runtime":{"env":{"TOKENIZERS_PARALLELISM":"false"},"environment":{"kind":"pip","packages":["sentence-transformers>=3"]},"kind":"python","python_version":"3.12"},"runtime_digest":"sha256:runtime","secret_bindings":[{"kind":"env","secret_ref":{"name":"hf-prod"},"variable":"HF_TOKEN"}],"signature":{"inputs":[{"arrow_type":"utf8","name":"text","nullable":true}],"output":{"arrow_type":"list<float32>","kind":"scalar","nullable":false}},"version":"fv_01K3EXACT"}
|
||||||
|
|||||||
Reference in New Issue
Block a user