mirror of
https://github.com/lancedb/lancedb.git
synced 2026-09-12 00:02:21 +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.
`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
d6bcfe52a9
commit
404b91d4d6
@@ -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,
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -24,6 +24,7 @@ import pytest
|
||||
import lancedb
|
||||
from lancedb.functions import (
|
||||
PythonRuntimeSpec,
|
||||
SecretBinding,
|
||||
UdfDefinition,
|
||||
_canonical_arrow_type,
|
||||
_GRAMMAR_PRIMITIVES,
|
||||
@@ -101,7 +102,7 @@ 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)
|
||||
|
||||
|
||||
@@ -121,7 +122,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()
|
||||
)
|
||||
|
||||
@@ -166,8 +167,10 @@ def test_a_binding_envelope_reaches_the_service_for_it_to_judge():
|
||||
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 = """
|
||||
@@ -1430,7 +1433,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"}
|
||||
@@ -1541,10 +1544,14 @@ def test_remote_registration_sends_bindings_and_never_a_credential():
|
||||
secrets=[EnvVarSecret(secret="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/functions/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.
|
||||
assert create_request == json.loads(
|
||||
analyze_caption.bind_secrets(
|
||||
|
||||
@@ -409,8 +409,8 @@ pub struct FunctionVersion {
|
||||
runtime: PythonRuntimeSpec,
|
||||
runtime_digest: String,
|
||||
environment_digest: String,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
secret_env_bindings: BTreeMap<String, String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
secret_bindings: Vec<SecretBinding>,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
@@ -449,8 +449,8 @@ impl FunctionVersion {
|
||||
/// them are not, and resolve at execution. Rotating a bound Secret
|
||||
/// therefore changes what the same version runs with, and no value has a
|
||||
/// field in this model.
|
||||
pub fn secret_env_bindings(&self) -> &BTreeMap<String, String> {
|
||||
&self.secret_env_bindings
|
||||
pub fn secret_bindings(&self) -> &[SecretBinding] {
|
||||
&self.secret_bindings
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> &str {
|
||||
@@ -493,10 +493,121 @@ pub struct FunctionArtifactRequest {
|
||||
pub adapter: PythonAdapterSpec,
|
||||
}
|
||||
|
||||
/// 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. A mode added later is a variant
|
||||
/// here, and the rules that are per-Function -- how many Secrets a Function may
|
||||
/// bind, which ones it needs -- stay answerable from one place.
|
||||
///
|
||||
/// Unknown kinds decode rather than failing the whole FunctionVersion, as
|
||||
/// [`PythonRuntimeSpec`] does for runtimes. The payload is intentionally not
|
||||
/// retained: the client does not proxy catalog values.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[non_exhaustive]
|
||||
pub enum SecretBinding {
|
||||
/// Delivered as an environment variable, which the UDF's library already
|
||||
/// reads. The variable is the delivery target; the Secret is what fills it.
|
||||
Env {
|
||||
variable: String,
|
||||
/// Named `secret_ref` rather than `secret` because a Job payload is
|
||||
/// scanned server-side for credential-shaped keys, and a key called
|
||||
/// `secret` trips that guard whatever it actually holds.
|
||||
secret_ref: String,
|
||||
},
|
||||
/// A binding kind introduced by a newer server.
|
||||
Unrecognized { kind: String },
|
||||
}
|
||||
|
||||
impl SecretBinding {
|
||||
/// The wire discriminator reported by Sophon.
|
||||
pub fn kind(&self) -> &str {
|
||||
match self {
|
||||
Self::Env { .. } => "env",
|
||||
Self::Unrecognized { kind } => kind,
|
||||
}
|
||||
}
|
||||
|
||||
/// The environment variable this binding fills, or `None` for a kind that
|
||||
/// does not deliver through one.
|
||||
pub fn variable(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Env { variable, .. } => Some(variable),
|
||||
Self::Unrecognized { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Secret bound, or `None` for a kind this client cannot read.
|
||||
pub fn secret(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Env { secret_ref, .. } => Some(secret_ref),
|
||||
Self::Unrecognized { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EnvSecretBindingWire {
|
||||
variable: String,
|
||||
secret_ref: String,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SecretBinding {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
|
||||
let value = Value::deserialize(deserializer)?;
|
||||
let kind = value
|
||||
.get("kind")
|
||||
.ok_or_else(|| de::Error::missing_field("kind"))?
|
||||
.as_str()
|
||||
.ok_or_else(|| de::Error::custom("secret binding kind must be a string"))?
|
||||
.to_string();
|
||||
match kind.as_str() {
|
||||
"env" => {
|
||||
let wire: EnvSecretBindingWire =
|
||||
serde_json::from_value(value).map_err(de::Error::custom)?;
|
||||
Ok(Self::Env {
|
||||
variable: wire.variable,
|
||||
secret_ref: wire.secret_ref,
|
||||
})
|
||||
}
|
||||
_ => Ok(Self::Unrecognized { kind }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for SecretBinding {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
|
||||
#[derive(Serialize)]
|
||||
struct EnvBindingRef<'a> {
|
||||
kind: &'static str,
|
||||
variable: &'a str,
|
||||
secret_ref: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct UnrecognizedBindingRef<'a> {
|
||||
kind: &'a str,
|
||||
}
|
||||
|
||||
match self {
|
||||
Self::Env {
|
||||
variable,
|
||||
secret_ref,
|
||||
} => EnvBindingRef {
|
||||
kind: "env",
|
||||
variable,
|
||||
secret_ref,
|
||||
}
|
||||
.serialize(serializer),
|
||||
Self::Unrecognized { kind } => UnrecognizedBindingRef { kind }.serialize(serializer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable request envelope for remote immutable Function registration.
|
||||
///
|
||||
/// 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 Sophon resolves inside the remote runtime.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FunctionRegistrationRequest {
|
||||
@@ -507,8 +618,8 @@ pub struct FunctionRegistrationRequest {
|
||||
/// Declared environment variable name to the Secret it binds. A binding is
|
||||
/// a reference: whether the Secret exists is answered when a column is
|
||||
/// declared against this version, not here.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub secret_env_bindings: BTreeMap<String, String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub secret_bindings: Vec<SecretBinding>,
|
||||
}
|
||||
|
||||
impl_json!(FunctionRegistrationRequest);
|
||||
|
||||
@@ -353,15 +353,16 @@ impl RemoteDatabase {
|
||||
}
|
||||
|
||||
impl<S: HttpSend> RemoteDatabase<S> {
|
||||
/// `create` and `alter` differ only in which name state the server
|
||||
/// requires, so they share one request shape. The value is a request field
|
||||
/// and never a path segment or query parameter, which keeps it out of
|
||||
/// access logs and proxy traces.
|
||||
async fn write_secret(&self, route: &str, name: &str, value: &str) -> Result<()> {
|
||||
let req = self.client.post(route).json(&serde_json::json!({
|
||||
"name": name,
|
||||
"value": value,
|
||||
}));
|
||||
/// Post a request whose body carries a credential.
|
||||
///
|
||||
/// Shared by the create and alter verbs, which declare their own request
|
||||
/// types: the two mean different things to the service and are free to
|
||||
/// diverge, so what they share is the posting and not the shape.
|
||||
///
|
||||
/// The value is a request field and never a path segment or query
|
||||
/// parameter, which keeps it out of access logs and proxy traces.
|
||||
async fn post_secret_write<T: serde::Serialize>(&self, route: &str, body: &T) -> Result<()> {
|
||||
let req = self.client.post(route).json(body);
|
||||
// This call is what says the body is a credential. Nothing downstream
|
||||
// can tell from the bytes, and a route list in the transport would have
|
||||
// to be kept in step with endpoints declared here.
|
||||
@@ -588,6 +589,25 @@ struct RemoteDropFunctionResponse {
|
||||
dropped: bool,
|
||||
}
|
||||
|
||||
/// Create a Secret under a name the database does not yet hold.
|
||||
///
|
||||
/// Declared separately from the alter request although the two are identical
|
||||
/// today: 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.
|
||||
#[derive(serde::Serialize)]
|
||||
struct RemoteCreateSecretRequest<'a> {
|
||||
name: &'a str,
|
||||
value: &'a str,
|
||||
}
|
||||
|
||||
/// Replace the credential behind a Secret the database already holds.
|
||||
#[derive(serde::Serialize)]
|
||||
struct RemoteAlterSecretRequest<'a> {
|
||||
name: &'a str,
|
||||
value: &'a str,
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// the other.
|
||||
@@ -714,11 +734,19 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
|
||||
}
|
||||
|
||||
async fn create_secret(&self, name: &str, value: &str) -> Result<()> {
|
||||
self.write_secret("/v1/secrets/create", name, value).await
|
||||
self.post_secret_write(
|
||||
"/v1/secrets/create",
|
||||
&RemoteCreateSecretRequest { name, value },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn alter_secret(&self, name: &str, value: &str) -> Result<()> {
|
||||
self.write_secret("/v1/secrets/alter", name, value).await
|
||||
self.post_secret_write(
|
||||
"/v1/secrets/alter",
|
||||
&RemoteAlterSecretRequest { name, value },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_secrets(&self) -> Result<Vec<String>> {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use lancedb::function::{
|
||||
FunctionApplication, FunctionBinding, FunctionVersion, RefreshColumnResult,
|
||||
FunctionApplication, FunctionBinding, FunctionVersion, RefreshColumnResult, SecretBinding,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -50,8 +49,11 @@ fn function_version_job_result_matches_shared_canonical_golden() {
|
||||
assert_eq!(version.version(), "fv_01K3EXACT");
|
||||
assert_eq!(version.runtime_digest(), "sha256:runtime");
|
||||
assert_eq!(
|
||||
version.secret_env_bindings(),
|
||||
&BTreeMap::from([("HF_TOKEN".to_string(), "hf-prod".to_string())])
|
||||
version.secret_bindings(),
|
||||
[SecretBinding::Env {
|
||||
variable: "HF_TOKEN".to_string(),
|
||||
secret_ref: "hf-prod".to_string(),
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
version.to_canonical_json().expect("canonical JSON"),
|
||||
@@ -180,12 +182,46 @@ fn canonical_client_values_carry_bindings_and_no_credentials() {
|
||||
.expect("canonical JSON");
|
||||
|
||||
assert_eq!(
|
||||
canonical["secret_env_bindings"],
|
||||
serde_json::json!({"HF_TOKEN": "hf-prod"})
|
||||
canonical["secret_bindings"],
|
||||
serde_json::json!([{"kind": "env", "variable": "HF_TOKEN", "secret_ref": "hf-prod"}])
|
||||
);
|
||||
assert_no_secret_values(&canonical);
|
||||
}
|
||||
|
||||
/// A binding kind a newer server introduces must not fail the whole version.
|
||||
///
|
||||
/// This is the cost the union pays for being one field: an unknown variant is
|
||||
/// a decode error unless it is caught, so it is caught -- and the payload is
|
||||
/// dropped rather than retained, as `PythonRuntimeSpec` does, because the
|
||||
/// client does not proxy catalog values.
|
||||
#[test]
|
||||
fn an_unknown_binding_kind_is_forward_decodable() {
|
||||
let mut result = job_result("remote_function_job.json");
|
||||
result["secret_bindings"] = serde_json::json!([
|
||||
{"kind": "env", "variable": "HF_TOKEN", "secret_ref": "hf-prod"},
|
||||
{"kind": "file", "path": "/run/secrets/tok", "secret_ref": "hf-prod"},
|
||||
]);
|
||||
|
||||
let version = FunctionVersion::from_json(&result.to_string()).expect("future binding kind");
|
||||
|
||||
let kinds = version
|
||||
.secret_bindings()
|
||||
.iter()
|
||||
.map(|binding| binding.kind())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(kinds, ["env", "file"]);
|
||||
assert_eq!(version.secret_bindings()[1].variable(), None);
|
||||
assert_eq!(version.secret_bindings()[1].secret(), None);
|
||||
|
||||
// The unknown kind round-trips as its discriminator and nothing more.
|
||||
let canonical: Value =
|
||||
serde_json::from_str(&version.to_canonical_json().expect("canonical")).expect("JSON");
|
||||
assert_eq!(
|
||||
canonical["secret_bindings"][1],
|
||||
serde_json::json!({"kind": "file"})
|
||||
);
|
||||
}
|
||||
|
||||
/// Every Function registered before Secrets existed serializes unchanged.
|
||||
#[test]
|
||||
fn a_version_without_bindings_keeps_the_original_wire_shape() {
|
||||
@@ -193,14 +229,14 @@ fn a_version_without_bindings_keeps_the_original_wire_shape() {
|
||||
result
|
||||
.as_object_mut()
|
||||
.expect("Function version object")
|
||||
.remove("secret_env_bindings");
|
||||
.remove("secret_bindings");
|
||||
let version = FunctionVersion::from_json(&result.to_string()).expect("FunctionVersion result");
|
||||
|
||||
assert!(version.secret_env_bindings().is_empty());
|
||||
assert!(version.secret_bindings().is_empty());
|
||||
assert!(
|
||||
!version
|
||||
.to_canonical_json()
|
||||
.expect("canonical FunctionVersion")
|
||||
.contains("secret_env_bindings")
|
||||
.contains("secret_bindings")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use lancedb::Error;
|
||||
use lancedb::function::FunctionRegistrationRequest;
|
||||
use lancedb::function::{FunctionRegistrationRequest, SecretBinding};
|
||||
use serde_json::Value;
|
||||
|
||||
fn fixture(name: &str) -> String {
|
||||
@@ -44,8 +44,8 @@ fn registration_request_matches_shared_canonical_golden() {
|
||||
assert_eq!(request.name, "normalize_score");
|
||||
assert_eq!(request.artifact.adapter.kind, "scalar_to_arrow_batch");
|
||||
// The unchanged path: a Function that binds nothing serializes today's
|
||||
// bytes, with no `secret_env_bindings` key at all.
|
||||
assert!(request.secret_env_bindings.is_empty());
|
||||
// bytes, with no `secret_bindings` key at all.
|
||||
assert!(request.secret_bindings.is_empty());
|
||||
assert_eq!(
|
||||
request.to_canonical_json().expect("canonical request"),
|
||||
fixture("remote_function_registration_request.canonical.json").trim()
|
||||
@@ -67,11 +67,11 @@ fn secret_bound_registration_request_matches_shared_canonical_golden() {
|
||||
.expect("registration request");
|
||||
assert_eq!(request.name, "analyze_caption");
|
||||
assert_eq!(
|
||||
request.secret_env_bindings,
|
||||
std::collections::BTreeMap::from([(
|
||||
"OPENAI_API_KEY".to_string(),
|
||||
"openai-prod".to_string()
|
||||
)])
|
||||
request.secret_bindings,
|
||||
[SecretBinding::Env {
|
||||
variable: "OPENAI_API_KEY".to_string(),
|
||||
secret_ref: "openai-prod".to_string(),
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
request.to_canonical_json().expect("canonical request"),
|
||||
|
||||
+35
-8
@@ -3,7 +3,9 @@
|
||||
"job_type": "create_function",
|
||||
"job_state": "DONE",
|
||||
"creation_ms": 1787270400000,
|
||||
"spec": {"name": "embed"},
|
||||
"spec": {
|
||||
"name": "embed"
|
||||
},
|
||||
"result": {
|
||||
"name": "embed",
|
||||
"version": "fv_01K3EXACT",
|
||||
@@ -13,19 +15,44 @@
|
||||
"entrypoint": "embed"
|
||||
},
|
||||
"signature": {
|
||||
"inputs": [{"name": "text", "arrow_type": "utf8", "nullable": true}],
|
||||
"output": {"kind": "scalar", "arrow_type": "list<float32>", "nullable": false}
|
||||
"inputs": [
|
||||
{
|
||||
"name": "text",
|
||||
"arrow_type": "utf8",
|
||||
"nullable": true
|
||||
}
|
||||
],
|
||||
"output": {
|
||||
"kind": "scalar",
|
||||
"arrow_type": "list<float32>",
|
||||
"nullable": false
|
||||
}
|
||||
},
|
||||
"runtime": {
|
||||
"kind": "python",
|
||||
"python_version": "3.12",
|
||||
"environment": {"kind": "pip", "packages": ["sentence-transformers>=3"]},
|
||||
"env": {"TOKENIZERS_PARALLELISM": "false"}
|
||||
"environment": {
|
||||
"kind": "pip",
|
||||
"packages": [
|
||||
"sentence-transformers>=3"
|
||||
]
|
||||
},
|
||||
"env": {
|
||||
"TOKENIZERS_PARALLELISM": "false"
|
||||
}
|
||||
},
|
||||
"runtime_digest": "sha256:runtime",
|
||||
"environment_digest": "sha256:environment",
|
||||
"secret_env_bindings": {"HF_TOKEN": "hf-prod"},
|
||||
"created_at": "2026-08-21T00:00:00Z"
|
||||
"created_at": "2026-08-21T00:00:00Z",
|
||||
"secret_bindings": [
|
||||
{
|
||||
"kind": "env",
|
||||
"variable": "HF_TOKEN",
|
||||
"secret_ref": "hf-prod"
|
||||
}
|
||||
]
|
||||
},
|
||||
"future_job": {"trace_id": "trace-1"}
|
||||
"future_job": {
|
||||
"trace_id": "trace-1"
|
||||
}
|
||||
}
|
||||
|
||||
+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_env_bindings":{"OPENAI_API_KEY":"openai-prod"},"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":"openai-prod","variable":"OPENAI_API_KEY"}],"signature":{"inputs":[{"arrow_type":"utf8","name":"caption","nullable":false}],"output":{"arrow_type":"utf8","kind":"scalar","nullable":false}}}
|
||||
|
||||
+8
-4
@@ -26,9 +26,6 @@
|
||||
"kind": "python",
|
||||
"python_version": "3.12"
|
||||
},
|
||||
"secret_env_bindings": {
|
||||
"OPENAI_API_KEY": "openai-prod"
|
||||
},
|
||||
"signature": {
|
||||
"inputs": [
|
||||
{
|
||||
@@ -42,5 +39,12 @@
|
||||
"kind": "scalar",
|
||||
"nullable": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"secret_bindings": [
|
||||
{
|
||||
"kind": "env",
|
||||
"variable": "OPENAI_API_KEY",
|
||||
"secret_ref": "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_env_bindings":{"HF_TOKEN":"hf-prod"},"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":"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