mirror of
https://github.com/lancedb/lancedb.git
synced 2026-11-13 14:16:54 +00:00
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.
208 lines
7.5 KiB
Python
208 lines
7.5 KiB
Python
# 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)
|