mirror of
https://github.com/lancedb/lancedb.git
synced 2026-11-13 14:16:54 +00:00
feat: add database storage integration APIs (#4618)
Adds database-owned S3 storage integrations with Secret-style namespace
paths in a separate keyspace. Use a regular database under the cluster
root for bootstrap integrations and Secrets. `default` is an example
name, with no enforced or automatically provisioned database; `system`
remains read-only. Authentication uses either an IAM role or two
existing Secret references, including cross-database references;
configuration metadata never contains credential values.
### Python experience
```python
catalog = lancedb.connect_catalog("https://my-server.example", api_key="secret")
db = catalog.connect_database("default")
info = db.create_storage_integration(
"raw", "s3://customer-bucket/raw/",
aws_role_arn="arn:aws:iam::123456789012:role/lancedb-access",
aws_region="us-east-1",
)
print(info.configuration_instructions)
info = db.describe_storage_integration("raw")
print(list(db.list_storage_integrations()))
db.drop_storage_integration("raw")
```
For static credentials, replace `aws_role_arn` with
`aws_access_key_id_secret=SecretReference("s3_key_id",
database="default")` and
`aws_secret_access_key_secret=SecretReference("s3_secret_key",
database="default")`, imported from `lancedb.storage_integration`. Pass
`namespace_path` to address nested namespaces. Async database
connections expose the same APIs; listing is lazy. All four client
operations execute SQL through the existing SQL transport, with typed
Arrow result decoding and name-based listing cursors.
### SQL experience
```sql
CREATE STORAGE INTEGRATION default.public.raw
STORAGE_PROVIDER = 'S3'
STORAGE_PREFIX = 's3://customer-bucket/raw/'
STORAGE_AWS_ROLE_ARN = 'arn:aws:iam::123456789012:role/lancedb-access'
STORAGE_AWS_REGION = 'us-east-1';
DESCRIBE STORAGE INTEGRATION default.public.raw;
SHOW STORAGE INTEGRATIONS IN default.public;
DROP STORAGE INTEGRATION IF EXISTS default.public.raw;
CREATE STORAGE INTEGRATION default.public.raw
STORAGE_PROVIDER = 'S3'
STORAGE_PREFIX = 's3://customer-bucket/raw/'
STORAGE_AWS_ACCESS_KEY_ID_SECRET = default.public.s3_key_id
STORAGE_AWS_SECRET_ACCESS_KEY_SECRET = default.public.s3_secret_key;
```
CREATE and DESCRIBE return configuration and a single sentence of setup
guidance. IAM mode also returns the deployment role principal, stable
external ID, customer trust policy, and prefix-scoped permissions
policy. Registration does not verify access. Creation requires namespace
CREATE_STORAGE_INTEGRATION plus parent USAGE; Secret references require
source database, namespace, and Secret USAGE. Describe requires object
USAGE; drop requires OWNERSHIP. SQL supports GRANT/REVOKE for these
objects and their creation privilege.
Includes client documentation and Flight SQL regression coverage; no
dedicated storage-integration REST endpoints are used. Local
builds/tests are excluded by request.
This commit is contained in:
@@ -41,6 +41,7 @@ from .functions import (
|
||||
)
|
||||
from .secrets import EnvVarSecret as EnvVarSecret
|
||||
from .secrets import SecretInfo as SecretInfo
|
||||
from .storage_integration import StorageIntegrationInfo as StorageIntegrationInfo
|
||||
from .materialized_view import (
|
||||
AsyncMaterializedView,
|
||||
MaterializedView,
|
||||
|
||||
@@ -171,6 +171,22 @@ class Connection(object):
|
||||
async def drop_function_async(
|
||||
self, name: str, version: str, namespace_path: Optional[List[str]] = None
|
||||
) -> Tuple[bool, Job]: ...
|
||||
async def create_storage_integration(
|
||||
self, name: str, request: str, namespace_path: Optional[List[str]] = None
|
||||
) -> str: ...
|
||||
async def describe_storage_integration(
|
||||
self, name: str, namespace_path: Optional[List[str]] = None
|
||||
) -> str: ...
|
||||
def list_storage_integrations(
|
||||
self,
|
||||
*,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
page_token: Optional[str] = None,
|
||||
page_limit: Optional[int] = None,
|
||||
) -> NameListing: ...
|
||||
async def drop_storage_integration(
|
||||
self, name: str, namespace_path: Optional[List[str]] = None
|
||||
) -> None: ...
|
||||
async def create_secret(
|
||||
self, name: str, value: str, namespace_path: Optional[List[str]] = None
|
||||
) -> None: ...
|
||||
@@ -1080,6 +1096,10 @@ class AuthzObject:
|
||||
database: str, namespace_path: Sequence[str], name: str
|
||||
) -> AuthzObject: ...
|
||||
@staticmethod
|
||||
def storage_integration(
|
||||
database: str, namespace: Sequence[str], name: str
|
||||
) -> AuthzObject: ...
|
||||
@staticmethod
|
||||
def secret(
|
||||
database: str, namespace_path: Sequence[str], name: str
|
||||
) -> AuthzObject: ...
|
||||
|
||||
@@ -172,6 +172,17 @@ class Object:
|
||||
"""A secret, defaulting to the database's public namespace."""
|
||||
return cls(str(_lancedb.AuthzObject.secret(database, namespace_path, name)))
|
||||
|
||||
@classmethod
|
||||
def storage_integration(
|
||||
cls, *, database: str, name: str, namespace_path: Sequence[str] = ("public",)
|
||||
) -> "Object":
|
||||
"""A database storage integration, defaulting to its public namespace."""
|
||||
return cls(
|
||||
str(
|
||||
_lancedb.AuthzObject.storage_integration(database, namespace_path, name)
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def function(
|
||||
cls, *, database: str, name: str, namespace_path: Sequence[str] = ("public",)
|
||||
@@ -217,6 +228,7 @@ class Privilege(str, Enum):
|
||||
CREATE_SECRET = "CREATE_SECRET"
|
||||
CREATE_FUNCTION = "CREATE_FUNCTION"
|
||||
OPERATE = "OPERATE"
|
||||
CREATE_STORAGE_INTEGRATION = "CREATE_STORAGE_INTEGRATION"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
"""Remote catalogs manage databases through a server's root namespace."""
|
||||
"""Remote catalogs manage databases."""
|
||||
|
||||
from functools import cached_property
|
||||
from datetime import timedelta
|
||||
|
||||
@@ -8,6 +8,8 @@ from abc import abstractmethod
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from dataclasses import asdict
|
||||
import json
|
||||
import sys
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
@@ -58,6 +60,7 @@ from . import __version__
|
||||
from ._lancedb import connect as lancedb_connect # type: ignore
|
||||
from .functions import FunctionVersion, UdfDefinition
|
||||
from .job import AsyncJob, Job, _typed_job
|
||||
from .storage_integration import SecretReference, StorageIntegrationInfo
|
||||
from .sql import AsyncQuery as AsyncSqlQuery
|
||||
from .sql import Query as SqlQuery
|
||||
from .sql import QueryDescription, QueryParameters, to_parameter_batch
|
||||
@@ -375,6 +378,38 @@ class DBConnection(EnforceOverrides):
|
||||
"""
|
||||
pass
|
||||
|
||||
def create_storage_integration(
|
||||
self,
|
||||
name: str,
|
||||
storage_prefix: str,
|
||||
*,
|
||||
aws_role_arn: Optional[str] = None,
|
||||
aws_access_key_id_secret: Optional[Union[str, SecretReference]] = None,
|
||||
aws_secret_access_key_secret: Optional[Union[str, SecretReference]] = None,
|
||||
aws_region: Optional[str] = None,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
) -> StorageIntegrationInfo:
|
||||
raise NotImplementedError
|
||||
|
||||
def describe_storage_integration(
|
||||
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||
) -> StorageIntegrationInfo:
|
||||
raise NotImplementedError
|
||||
|
||||
def list_storage_integrations(
|
||||
self,
|
||||
*,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
page_token: Optional[str] = None,
|
||||
page_limit: Optional[int] = None,
|
||||
) -> Listing[str]:
|
||||
raise NotImplementedError
|
||||
|
||||
def drop_storage_integration(
|
||||
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def create_table(
|
||||
self,
|
||||
@@ -2099,6 +2134,62 @@ class LanceDBConnection(DBConnection):
|
||||
)
|
||||
return dropped, Job(job)
|
||||
|
||||
@override
|
||||
def create_storage_integration(
|
||||
self,
|
||||
name: str,
|
||||
storage_prefix: str,
|
||||
*,
|
||||
aws_role_arn: Optional[str] = None,
|
||||
aws_access_key_id_secret: Optional[Union[str, SecretReference]] = None,
|
||||
aws_secret_access_key_secret: Optional[Union[str, SecretReference]] = None,
|
||||
aws_region: Optional[str] = None,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
) -> StorageIntegrationInfo:
|
||||
return LOOP.run(
|
||||
self._conn.create_storage_integration(
|
||||
name,
|
||||
storage_prefix,
|
||||
aws_role_arn=aws_role_arn,
|
||||
aws_access_key_id_secret=aws_access_key_id_secret,
|
||||
aws_secret_access_key_secret=aws_secret_access_key_secret,
|
||||
aws_region=aws_region,
|
||||
namespace_path=namespace_path,
|
||||
)
|
||||
)
|
||||
|
||||
@override
|
||||
def describe_storage_integration(
|
||||
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||
) -> StorageIntegrationInfo:
|
||||
return LOOP.run(
|
||||
self._conn.describe_storage_integration(name, namespace_path=namespace_path)
|
||||
)
|
||||
|
||||
@override
|
||||
def list_storage_integrations(
|
||||
self,
|
||||
*,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
page_token: Optional[str] = None,
|
||||
page_limit: Optional[int] = None,
|
||||
) -> Listing[str]:
|
||||
return Listing(
|
||||
self._conn.list_storage_integrations(
|
||||
namespace_path=namespace_path,
|
||||
page_token=page_token,
|
||||
page_limit=page_limit,
|
||||
)
|
||||
)
|
||||
|
||||
@override
|
||||
def drop_storage_integration(
|
||||
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||
) -> None:
|
||||
LOOP.run(
|
||||
self._conn.drop_storage_integration(name, namespace_path=namespace_path)
|
||||
)
|
||||
|
||||
@override
|
||||
def create_secret(
|
||||
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
|
||||
@@ -3267,6 +3358,102 @@ class AsyncConnection(object):
|
||||
)
|
||||
return dropped, AsyncJob(job)
|
||||
|
||||
async def create_storage_integration(
|
||||
self,
|
||||
name: str,
|
||||
storage_prefix: str,
|
||||
*,
|
||||
aws_role_arn: Optional[str] = None,
|
||||
aws_access_key_id_secret: Optional[Union[str, SecretReference]] = None,
|
||||
aws_secret_access_key_secret: Optional[Union[str, SecretReference]] = None,
|
||||
aws_region: Optional[str] = None,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
) -> StorageIntegrationInfo:
|
||||
"""Register database-owned S3 access using a role or two Secret references.
|
||||
|
||||
``namespace_path`` follows Secret naming rules. A SecretReference can name
|
||||
another database; a string names a root Secret in this database.
|
||||
Registration returns setup instructions without verifying storage access.
|
||||
"""
|
||||
role_mode = aws_role_arn is not None
|
||||
key_mode = (
|
||||
aws_access_key_id_secret is not None
|
||||
and aws_secret_access_key_secret is not None
|
||||
)
|
||||
if role_mode == key_mode or (
|
||||
role_mode
|
||||
and (
|
||||
aws_access_key_id_secret is not None
|
||||
or aws_secret_access_key_secret is not None
|
||||
)
|
||||
):
|
||||
raise ValueError(
|
||||
"Specify either aws_role_arn or both AWS credential Secret references"
|
||||
)
|
||||
|
||||
def reference(value):
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
value = SecretReference(value)
|
||||
if not isinstance(value, SecretReference):
|
||||
raise TypeError(
|
||||
"Credential secrets must be names or SecretReference objects"
|
||||
)
|
||||
return asdict(value)
|
||||
|
||||
request = {
|
||||
"storage_provider": "S3",
|
||||
"storage_prefix": storage_prefix,
|
||||
"aws_role_arn": aws_role_arn,
|
||||
"aws_region": aws_region,
|
||||
"aws_access_key_id_secret": reference(aws_access_key_id_secret),
|
||||
"aws_secret_access_key_secret": reference(aws_secret_access_key_secret),
|
||||
}
|
||||
info = await self._inner.create_storage_integration(
|
||||
validate_secret_name(name),
|
||||
json.dumps(request),
|
||||
list(validate_namespace_path(namespace_path)),
|
||||
)
|
||||
return StorageIntegrationInfo(**json.loads(info))
|
||||
|
||||
async def describe_storage_integration(
|
||||
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||
) -> StorageIntegrationInfo:
|
||||
"""Return configuration and Secret references without credential values."""
|
||||
return StorageIntegrationInfo(
|
||||
**json.loads(
|
||||
await self._inner.describe_storage_integration(
|
||||
validate_secret_name(name),
|
||||
list(validate_namespace_path(namespace_path)),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def list_storage_integrations(
|
||||
self,
|
||||
*,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
page_token: Optional[str] = None,
|
||||
page_limit: Optional[int] = None,
|
||||
) -> AsyncListing[str]:
|
||||
"""Lazily list one namespace of this database's storage integrations."""
|
||||
return AsyncListing(
|
||||
self._inner.list_storage_integrations(
|
||||
namespace_path=list(validate_namespace_path(namespace_path)),
|
||||
page_token=page_token,
|
||||
page_limit=page_limit,
|
||||
)
|
||||
)
|
||||
|
||||
async def drop_storage_integration(
|
||||
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||
) -> None:
|
||||
"""Drop a storage integration with ownership permission."""
|
||||
await self._inner.drop_storage_integration(
|
||||
validate_secret_name(name), list(validate_namespace_path(namespace_path))
|
||||
)
|
||||
|
||||
async def create_secret(
|
||||
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
|
||||
) -> None:
|
||||
|
||||
@@ -43,6 +43,7 @@ from ..sql import Query as SqlQuery
|
||||
from ..sql import QueryDescription, QueryParameters
|
||||
from ..materialized_view import MaterializedView, MaterializedViewSource, SelectArg
|
||||
from ..secrets import EnvVarSecret, SecretInfo
|
||||
from ..storage_integration import SecretReference, StorageIntegrationInfo
|
||||
from ..view import ViewDescription
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -1036,6 +1037,62 @@ class RemoteDBConnection(DBConnection):
|
||||
)
|
||||
return dropped, Job(job)
|
||||
|
||||
@override
|
||||
def create_storage_integration(
|
||||
self,
|
||||
name: str,
|
||||
storage_prefix: str,
|
||||
*,
|
||||
aws_role_arn: Optional[str] = None,
|
||||
aws_access_key_id_secret: Optional[Union[str, SecretReference]] = None,
|
||||
aws_secret_access_key_secret: Optional[Union[str, SecretReference]] = None,
|
||||
aws_region: Optional[str] = None,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
) -> StorageIntegrationInfo:
|
||||
return LOOP.run(
|
||||
self._conn.create_storage_integration(
|
||||
name,
|
||||
storage_prefix,
|
||||
aws_role_arn=aws_role_arn,
|
||||
aws_access_key_id_secret=aws_access_key_id_secret,
|
||||
aws_secret_access_key_secret=aws_secret_access_key_secret,
|
||||
aws_region=aws_region,
|
||||
namespace_path=namespace_path,
|
||||
)
|
||||
)
|
||||
|
||||
@override
|
||||
def describe_storage_integration(
|
||||
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||
) -> StorageIntegrationInfo:
|
||||
return LOOP.run(
|
||||
self._conn.describe_storage_integration(name, namespace_path=namespace_path)
|
||||
)
|
||||
|
||||
@override
|
||||
def list_storage_integrations(
|
||||
self,
|
||||
*,
|
||||
namespace_path: Optional[List[str]] = None,
|
||||
page_token: Optional[str] = None,
|
||||
page_limit: Optional[int] = None,
|
||||
) -> Listing[str]:
|
||||
return Listing(
|
||||
self._conn.list_storage_integrations(
|
||||
namespace_path=namespace_path,
|
||||
page_token=page_token,
|
||||
page_limit=page_limit,
|
||||
)
|
||||
)
|
||||
|
||||
@override
|
||||
def drop_storage_integration(
|
||||
self, name: str, *, namespace_path: Optional[List[str]] = None
|
||||
) -> None:
|
||||
LOOP.run(
|
||||
self._conn.drop_storage_integration(name, namespace_path=namespace_path)
|
||||
)
|
||||
|
||||
@override
|
||||
def create_secret(
|
||||
self, name: str, value: str, *, namespace_path: Optional[List[str]] = None
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
"""Database-owned storage integrations and references to credential Secrets."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Literal, Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SecretReference:
|
||||
"""A credential Secret, optionally qualified by database and namespace."""
|
||||
|
||||
name: str
|
||||
database: Optional[str] = None
|
||||
namespace_path: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StorageIntegrationInfo:
|
||||
"""Registered S3 configuration and policies to give to your AWS administrator.
|
||||
|
||||
``configuration_instructions`` is a single sentence of setup guidance for
|
||||
display; its wording may change. Use the structured identity and policy fields
|
||||
instead of parsing that text. Creating this object does not verify AWS access
|
||||
or configure a storage operation to use it. Static-key authentication stores
|
||||
only Secret references. No field contains AWS credentials.
|
||||
"""
|
||||
|
||||
name: str
|
||||
database: str
|
||||
namespace_path: list[str]
|
||||
storage_provider: Literal["S3"]
|
||||
storage_prefix: str
|
||||
aws_region: Optional[str]
|
||||
aws_account_id: str
|
||||
aws_iam_principal_arn: str
|
||||
aws_external_id: str
|
||||
created_at_millis: int
|
||||
access_verified: bool
|
||||
trust_policy: Optional[Dict[str, Any]]
|
||||
permissions_policy: Dict[str, Any]
|
||||
configuration_instructions: str
|
||||
aws_role_arn: Optional[str] = None
|
||||
aws_access_key_id_secret: Optional[SecretReference] = None
|
||||
aws_secret_access_key_secret: Optional[SecretReference] = None
|
||||
|
||||
def __post_init__(self):
|
||||
for field in ("aws_access_key_id_secret", "aws_secret_access_key_secret"):
|
||||
value = getattr(self, field)
|
||||
if isinstance(value, dict):
|
||||
object.__setattr__(self, field, SecretReference(**value))
|
||||
@@ -0,0 +1,207 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import pyarrow as pa
|
||||
import pyarrow.flight as flight
|
||||
|
||||
import lancedb
|
||||
from lancedb.authz import Object, Privilege
|
||||
from lancedb.db import AsyncConnection, DBConnection
|
||||
from lancedb.catalog import Catalog, AsyncCatalog
|
||||
from lancedb.storage_integration import SecretReference, StorageIntegrationInfo
|
||||
|
||||
|
||||
INFO = json.loads(
|
||||
(
|
||||
Path(__file__).parents[3]
|
||||
/ "rust/lancedb/tests/fixtures/storage_integration.json"
|
||||
).read_text()
|
||||
)
|
||||
NAME = "raw"
|
||||
ROLE = "arn:aws:iam::123456789012:role/access"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def remote_server(text_type=pa.string()):
|
||||
calls = []
|
||||
info = dict(INFO)
|
||||
|
||||
class Server(flight.FlightServerBase):
|
||||
def __init__(self):
|
||||
super().__init__("grpc://127.0.0.1:0")
|
||||
|
||||
def do_get(self, context, ticket):
|
||||
command = ticket.ticket
|
||||
calls.append(command)
|
||||
if b"SHOW STORAGE INTEGRATIONS" in command:
|
||||
result = pa.table({"name": pa.array(["zeta", NAME], type=text_type)})
|
||||
elif b"DROP STORAGE INTEGRATION" in command:
|
||||
result = pa.table({"dropped": [True]})
|
||||
else:
|
||||
if b"STORAGE_AWS_ACCESS_KEY_ID_SECRET" in command:
|
||||
info["aws_role_arn"] = None
|
||||
info["trust_policy"] = None
|
||||
info["aws_access_key_id_secret"] = {
|
||||
"database": "shared",
|
||||
"namespace_path": ["credentials"],
|
||||
"name": "access_key",
|
||||
}
|
||||
info["aws_secret_access_key_secret"] = {
|
||||
"database": "shared",
|
||||
"namespace_path": ["credentials"],
|
||||
"name": "secret_key",
|
||||
}
|
||||
row = dict(info)
|
||||
for field in [
|
||||
"namespace_path",
|
||||
"trust_policy",
|
||||
"permissions_policy",
|
||||
"aws_access_key_id_secret",
|
||||
"aws_secret_access_key_secret",
|
||||
]:
|
||||
if field in row and row[field] is not None:
|
||||
row[field] = json.dumps(row[field])
|
||||
row.setdefault("aws_access_key_id_secret", None)
|
||||
row.setdefault("aws_secret_access_key_secret", None)
|
||||
result = pa.table(
|
||||
{
|
||||
field: pa.array(
|
||||
[value],
|
||||
type=(
|
||||
pa.bool_()
|
||||
if field == "access_verified"
|
||||
else pa.int64()
|
||||
if field == "created_at_millis"
|
||||
else text_type
|
||||
),
|
||||
)
|
||||
for field, value in row.items()
|
||||
}
|
||||
)
|
||||
return flight.RecordBatchStream(result)
|
||||
|
||||
with Server() as server:
|
||||
yield f"grpc://127.0.0.1:{server.port}", calls
|
||||
|
||||
|
||||
def connect(host):
|
||||
return lancedb.connect(
|
||||
"db://default",
|
||||
api_key="fake",
|
||||
sql_host_override=host,
|
||||
host_override="http://127.0.0.1:1",
|
||||
)
|
||||
|
||||
|
||||
def test_storage_integration_methods_belong_to_databases():
|
||||
assert hasattr(DBConnection, "create_storage_integration")
|
||||
assert hasattr(AsyncConnection, "create_storage_integration")
|
||||
assert not hasattr(Catalog, "create_storage_integration")
|
||||
assert not hasattr(AsyncCatalog, "create_storage_integration")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text_type", [pa.string(), pa.large_string(), pa.string_view()]
|
||||
)
|
||||
def test_sync_storage_integration_lifecycle_and_aws_information(text_type):
|
||||
with remote_server(text_type) as (host, calls):
|
||||
db = connect(host)
|
||||
info = db.create_storage_integration(
|
||||
NAME, INFO["storage_prefix"], aws_role_arn=ROLE
|
||||
)
|
||||
assert isinstance(info, lancedb.StorageIntegrationInfo)
|
||||
assert info.aws_account_id == "001122334455"
|
||||
assert not info.access_verified
|
||||
assert (
|
||||
info.trust_policy["Statement"][0]["Condition"]["StringEquals"][
|
||||
"sts:ExternalId"
|
||||
]
|
||||
== info.aws_external_id
|
||||
)
|
||||
assert db.describe_storage_integration(NAME) == info
|
||||
listing = db.list_storage_integrations(page_limit=1)
|
||||
assert len(calls) == 2
|
||||
assert list(listing) == ["raw", "zeta"]
|
||||
assert db.drop_storage_integration(NAME) is None
|
||||
assert b'CREATE STORAGE INTEGRATION "raw"' in calls[0]
|
||||
assert b"STORAGE_AWS_ROLE_ARN = '" + ROLE.encode() + b"'" in calls[0]
|
||||
assert b'DROP STORAGE INTEGRATION "raw"' in calls[-1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_storage_integration_lifecycle():
|
||||
with remote_server() as (host, _calls):
|
||||
db = await lancedb.connect_async(
|
||||
"db://default",
|
||||
api_key="fake",
|
||||
sql_host_override=host,
|
||||
host_override="http://127.0.0.1:1",
|
||||
)
|
||||
info = await db.create_storage_integration(
|
||||
NAME, INFO["storage_prefix"], aws_role_arn=ROLE
|
||||
)
|
||||
assert await db.describe_storage_integration(NAME) == info
|
||||
assert [name async for name in db.list_storage_integrations()] == [
|
||||
"raw",
|
||||
"zeta",
|
||||
]
|
||||
await db.drop_storage_integration(NAME)
|
||||
with pytest.raises(ValueError):
|
||||
await db.describe_storage_integration("a//b")
|
||||
|
||||
|
||||
def test_integration_authorization_object_and_creation_privilege():
|
||||
value = Object.storage_integration(database="default", name=NAME)
|
||||
assert str(value) == "storage_integration:default:public$raw"
|
||||
assert Privilege.CREATE_STORAGE_INTEGRATION.value == "CREATE_STORAGE_INTEGRATION"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text_type", [pa.string(), pa.large_string(), pa.string_view()]
|
||||
)
|
||||
def test_static_credentials_use_explicit_cross_database_secret_references(text_type):
|
||||
with remote_server(text_type) as (host, calls):
|
||||
db = connect(host)
|
||||
info = db.create_storage_integration(
|
||||
"raw",
|
||||
INFO["storage_prefix"],
|
||||
aws_access_key_id_secret=SecretReference(
|
||||
"access_key", database="shared", namespace_path=("credentials",)
|
||||
),
|
||||
aws_secret_access_key_secret=SecretReference(
|
||||
"secret_key", database="shared", namespace_path=("credentials",)
|
||||
),
|
||||
)
|
||||
assert info.aws_role_arn is None
|
||||
assert db.describe_storage_integration("raw") == info
|
||||
assert info.aws_access_key_id_secret.database == "shared"
|
||||
assert b"STORAGE_AWS_ROLE_ARN" not in calls[0]
|
||||
assert (
|
||||
b'STORAGE_AWS_ACCESS_KEY_ID_SECRET = "shared"."credentials"."access_key"'
|
||||
in calls[0]
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
db.create_storage_integration(
|
||||
"bad",
|
||||
INFO["storage_prefix"],
|
||||
aws_role_arn=ROLE,
|
||||
aws_access_key_id_secret="key",
|
||||
)
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_metadata_defaults_an_omitted_role_to_none():
|
||||
data = dict(INFO)
|
||||
data.pop("aws_role_arn")
|
||||
assert StorageIntegrationInfo(**data).aws_role_arn is None
|
||||
|
||||
|
||||
def test_namespace_connections_keep_existing_apis_available(tmp_path):
|
||||
db = lancedb.connect_namespace("dir", {"root": str(tmp_path)})
|
||||
with pytest.raises(NotImplementedError):
|
||||
db.create_storage_integration("raw", "s3://customer/raw/", aws_role_arn=ROLE)
|
||||
@@ -262,6 +262,19 @@ impl AuthzObject {
|
||||
}))
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn storage_integration(
|
||||
database: &str,
|
||||
namespace: &Bound<'_, PyAny>,
|
||||
name: &str,
|
||||
) -> PyResult<Self> {
|
||||
Self::validated(Object::StorageIntegration(StorageIntegrationObject {
|
||||
database: database.into(),
|
||||
namespace: namespace_path(namespace)?,
|
||||
storage_integration: name.into(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[staticmethod]
|
||||
fn function(database: &str, namespace: &Bound<'_, PyAny>, name: &str) -> PyResult<Self> {
|
||||
Self::validated(Object::Function(FunctionObject {
|
||||
|
||||
@@ -888,6 +888,72 @@ impl Connection {
|
||||
})
|
||||
}
|
||||
|
||||
#[pyo3(signature = (name, request, namespace_path=None))]
|
||||
pub fn create_storage_integration<'py>(
|
||||
&self,
|
||||
py: Python<'py>,
|
||||
name: String,
|
||||
request: String,
|
||||
namespace_path: Option<Vec<String>>,
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let inner = self.get_inner()?.clone();
|
||||
let request: lancedb::storage_integration::CreateStorageIntegrationRequest =
|
||||
serde_json::from_str(&request)
|
||||
.map_err(|error| PyValueError::new_err(error.to_string()))?;
|
||||
future_into_py(py, async move {
|
||||
let info = inner
|
||||
.create_storage_integration(name, request, &namespace_path.unwrap_or_default())
|
||||
.await
|
||||
.infer_error()?;
|
||||
serde_json::to_string(&info).map_err(|error| PyValueError::new_err(error.to_string()))
|
||||
})
|
||||
}
|
||||
#[pyo3(signature = (name, namespace_path=None))]
|
||||
pub fn describe_storage_integration<'py>(
|
||||
&self,
|
||||
py: Python<'py>,
|
||||
name: String,
|
||||
namespace_path: Option<Vec<String>>,
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let inner = self.get_inner()?.clone();
|
||||
future_into_py(py, async move {
|
||||
let info = inner
|
||||
.describe_storage_integration(name, &namespace_path.unwrap_or_default())
|
||||
.await
|
||||
.infer_error()?;
|
||||
serde_json::to_string(&info).map_err(|error| PyValueError::new_err(error.to_string()))
|
||||
})
|
||||
}
|
||||
#[pyo3(signature = (*, namespace_path=None, page_token=None, page_limit=None))]
|
||||
pub fn list_storage_integrations(
|
||||
&self,
|
||||
namespace_path: Option<Vec<String>>,
|
||||
page_token: Option<String>,
|
||||
page_limit: Option<u32>,
|
||||
) -> PyResult<crate::listing::NameListing> {
|
||||
Ok(crate::listing::NameListing::new(
|
||||
self.get_inner()?.list_storage_integrations(
|
||||
&namespace_path.unwrap_or_default(),
|
||||
crate::listing::options(page_token, page_limit),
|
||||
),
|
||||
))
|
||||
}
|
||||
#[pyo3(signature = (name, namespace_path=None))]
|
||||
pub fn drop_storage_integration<'py>(
|
||||
&self,
|
||||
py: Python<'py>,
|
||||
name: String,
|
||||
namespace_path: Option<Vec<String>>,
|
||||
) -> PyResult<Bound<'py, PyAny>> {
|
||||
let inner = self.get_inner()?.clone();
|
||||
future_into_py(py, async move {
|
||||
inner
|
||||
.drop_storage_integration(name, &namespace_path.unwrap_or_default())
|
||||
.await
|
||||
.infer_error()
|
||||
})
|
||||
}
|
||||
|
||||
#[pyo3(signature = (name, value, namespace_path=None))]
|
||||
pub fn create_secret(
|
||||
self_: PyRef<'_, Self>,
|
||||
|
||||
Reference in New Issue
Block a user