Files
seaweedfs/test/s3tables/catalog_lancedb/lancedb_ops.py
T
Chris LuandDevin d15396fc4d s3tables: scope the anonymous identity's grant on the table catalogs (#12056)
* s3tables: scope the resolved anonymous identity instead of defaulting to allow

Catalog authorization fell back to the open default whenever the request
resolved to the configured anonymous identity, ignoring its grant. The
fallback now applies only when no principal was resolved (zero-config),
so anonymous requests are checked against the identity's configured
actions like any other principal. The S3 Tables API and the Iceberg and
Lance catalogs share this handler, so all three are covered.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test: authenticate the catalog clients in the s3tables suites

Anonymous calls into the Lance and Iceberg catalogs are now scoped by
the anonymous identity's grant, so the suites that used to rely on the
anonymous pass authenticate like their real deployments do: x-api-key
for the Lance probes and REST drivers, client-credentials OAuth for
pyiceberg.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* worker(lance): authenticate namespace calls with the storage credential

Anonymous catalog calls are now scoped by the anonymous grant, so a
worker that holds an S3 credential sends it as x-api-key on the
namespace requests instead of relying on the anonymous pass.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test: authenticate the pushdown catalog connection in catalog_lancedb

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* lance: follow catalog redirects only within the same host

reqwest forwards custom headers on a follow, so a redirect to another
host would carry the x-api-key credential to a server it was never
meant for. Keep same-host hops for proxies that normalize the path or
upgrade the scheme.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* lance: warn when the catalog api key travels in cleartext

The x-api-key pair is a real storage credential: over plain http to a
non-loopback host the network can read it. Warn at client build so the
operator sees it; https and loopback stay quiet, and the key still
sends since an http catalog is a valid deployment.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* lance: require the same origin on catalog redirects

A same-host hop can still downgrade https to http and put the api key on
the wire; matching scheme, host, and port keeps the credential on the
listener it was issued for.

* lance: bound catalog redirect hops so loops fail fast

The custom redirect policy drops reqwest's default 10-hop limit, so a
catalog that redirects back to itself would hold each call open until
the request timeout. Keep the bound alongside the origin check.

* lance: gofmt the redirect helpers

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-25 06:13:22 -07:00

261 lines
10 KiB
Python

#!/usr/bin/env python3
"""Drive the SeaweedFS Lance Namespace with LanceDB.
The existing client test uses `lance_namespace` and `pylance` directly, which is
the protocol's reference client. LanceDB is what people actually point at a
catalog: it connects with `connect_namespace("rest", ...)`, lists what is there,
opens a table and searches it. This checks the catalog against that path, the
way the Spark, Trino and ClickHouse suites check the Iceberg one.
Everything it prints is either "PASS" or a line starting with FAIL, so the Go
harness can report the first real failure rather than a stack trace.
"""
import argparse
import sys
import warnings
warnings.filterwarnings("ignore")
import lance
import lance_namespace as ln
import lancedb
import pyarrow as pa
DIM = 8
def sample_rows(count):
"""A vector table, which is the only kind worth putting in Lance."""
return pa.table(
{
"id": pa.array(list(range(count)), type=pa.int64()),
"title": pa.array([f"row-{i}" for i in range(count)]),
"vector": pa.array(
[[float(i) + d for d in range(DIM)] for i in range(count)],
type=pa.list_(pa.float32(), DIM),
),
}
)
def seed_table(namespace_url, storage, bucket, namespace, table, rows):
"""Declares a table through the namespace and writes a dataset into it.
LanceDB reads through the catalog; the writing half is pylance, because the
namespace records where a table lives and does not carry its data.
"""
ns = ln.connect(
"rest",
{
"uri": namespace_url,
"header.x-api-key": (
f"{storage['aws_access_key_id']}:{storage['aws_secret_access_key']}"
),
},
)
ns.create_namespace(
ln.CreateNamespaceRequest(id=[bucket], mode="EXIST_OK")
)
ns.create_namespace(
ln.CreateNamespaceRequest(id=[bucket, namespace], mode="EXIST_OK")
)
table_id = [bucket, namespace, table]
declared = ns.declare_table(ln.DeclareTableRequest(id=table_id))
lance.write_dataset(
sample_rows(rows), declared.location, storage_options=storage, mode="overwrite"
)
print(f"seeded {rows} rows at {declared.location}")
return declared.location
def check(condition, message):
if not condition:
print(f"FAIL: {message}", file=sys.stderr)
raise SystemExit(1)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--namespace-url", required=True)
parser.add_argument("--s3-endpoint", required=True)
parser.add_argument("--bucket", required=True)
parser.add_argument("--namespace", default="ml")
parser.add_argument("--table", default="embeddings")
# Enough rows for an IVF_PQ index to be worth building, which is the point
# of step 4: without one, a search is a brute-force scan.
parser.add_argument("--rows", type=int, default=1024)
parser.add_argument("--access-key", default="any")
parser.add_argument("--secret-key", default="any")
args = parser.parse_args()
# The namespace vends an endpoint correct for its own host; a container
# reaches the same gateway by another name, so the endpoint is overridden
# here and the credentials filled in for a deployment without STS.
storage = {
"aws_endpoint": args.s3_endpoint,
"allow_http": "true",
"aws_access_key_id": args.access_key,
"aws_secret_access_key": args.secret_key,
"aws_region": "us-east-1",
}
location = seed_table(
args.namespace_url, storage, args.bucket, args.namespace, args.table, args.rows
)
print(f"lancedb {lancedb.__version__} connecting to {args.namespace_url}")
db = lancedb.connect_namespace(
"rest",
{
"uri": args.namespace_url,
"header.x-api-key": f"{args.access_key}:{args.secret_key}",
},
storage_options=storage,
)
# 1. The catalog is browsable: the bucket is a namespace, and the table is
# in it under the name the namespace gave it.
tables = list(db.table_names(namespace_path=[args.bucket, args.namespace], limit=100))
print(f"table_names -> {tables}")
check(
any(args.table in name for name in tables),
f"{args.table} is not listed in {tables}",
)
# 2. Opening it goes through the catalog: LanceDB asks the namespace where
# the table is and reads it from there.
# storage_options is passed per call as well as on the connection: what the
# namespace vends for a table is merged in, and a deployment without STS
# vends no credentials, which is what the client would otherwise be left with.
table = db.open_table(
args.table,
namespace_path=[args.bucket, args.namespace],
storage_options=storage,
)
count = table.count_rows()
print(f"open_table -> {count} rows")
check(count == args.rows, f"read {count} rows, want {args.rows}")
# 3. The schema survived the round trip, vector column included. This is the
# part a catalog that only records a location cannot fake.
names = table.schema.names
print(f"schema -> {names}")
check("vector" in names and "title" in names, f"schema lost columns: {names}")
# 4. Build a vector index, then search it. Both halves matter: an index
# writes files into a directory of the table the S3 door has to admit -
# the layout guard has refused a Lance directory before - and without one
# a search is a brute-force scan that proves nothing about the index path
# the maintenance worker exists to keep in shape.
table.create_index(
metric="l2",
vector_column_name="vector",
index_type="IVF_PQ",
num_partitions=1,
num_sub_vectors=4,
)
indices = table.list_indices()
print(f"create_index -> {indices}")
check(len(indices) >= 1, "no index was created")
# Vectors are laid out so that id N sits near id N+1, so a query built from
# id 1 should come back with its neighbourhood. The assertion is a
# neighbourhood and not an exact id: an IVF_PQ index quantizes, so the
# nearest hit is approximate by construction - with this data it answers 0
# as readily as 1, and both are right.
query = [float(1) + d for d in range(DIM)]
hits = table.search(query).limit(3).to_list()
ids = [hit["id"] for hit in hits]
print(f"search -> {ids}")
check(len(hits) == 3, f"search returned {len(hits)} hits, want 3")
check(
all(i <= 5 for i in ids),
f"search returned {ids}, which is not the neighbourhood of the query",
)
# 5. A filtered scan, so it is not only the ANN path that works.
filtered = table.search().where("id < 5").limit(10).to_list()
print(f"filtered scan -> {len(filtered)} rows")
check(len(filtered) == 5, f"filter returned {len(filtered)} rows, want 5")
# 6. Creating a table. By default LanceDB declares it through the namespace
# and writes the data itself, which is exactly the split this catalog
# serves, so this has to work.
created = db.create_table(
"created_by_lancedb",
data=sample_rows(4),
namespace_path=[args.bucket, args.namespace],
storage_options=storage,
)
check(created.count_rows() == 4, "create_table wrote the wrong number of rows")
listed = list(db.table_names(namespace_path=[args.bucket, args.namespace], limit=100))
check(
any("created_by_lancedb" in name for name in listed),
f"a table created through LanceDB is not listed: {listed}",
)
print(f"create_table -> {created.count_rows()} rows, listed by the catalog")
# 7. The same creation with server-side pushdown, which asks the namespace
# to run CreateTable itself. That operation carries Arrow data and this
# catalog answers the spec's Unsupported for it. What matters is that the
# client is left with something coherent either way - it falls back to
# declare-and-write - rather than a hang, a 404, or a half-made table.
pushdown = lancedb.connect_namespace(
"rest",
{
"uri": args.namespace_url,
"header.x-api-key": f"{args.access_key}:{args.secret_key}",
},
storage_options=storage,
namespace_client_pushdown_operations=["CreateTable"],
)
pushed_error = None
try:
pushdown.create_table(
"pushed_by_lancedb",
data=sample_rows(2),
namespace_path=[args.bucket, args.namespace],
storage_options=storage,
)
except Exception as err: # noqa: BLE001 - the point is what the client sees
pushed_error = err
after = list(db.table_names(namespace_path=[args.bucket, args.namespace], limit=100))
landed = any("pushed_by_lancedb" in name for name in after)
print(f"create_table with pushdown: error={pushed_error!r}, catalog has it={landed}")
if pushed_error is None:
# The client fell back to declare-and-write, so the table is real and
# has to be readable and complete.
check(landed, "create_table reported success but the catalog has no table")
rows = db.open_table(
"pushed_by_lancedb",
namespace_path=[args.bucket, args.namespace],
storage_options=storage,
).count_rows()
check(rows == 2, f"the pushed table holds {rows} rows, want 2")
else:
# Refused, which is what this catalog answers for a data-plane
# operation. It has to be that refusal and not some other failure, and
# it must not have left a half-made table behind.
message = str(pushed_error).lower()
check(
"unsupported" in message or "501" in message or "not implemented" in message,
f"pushdown failed for an unexpected reason: {pushed_error}",
)
check(not landed, "a refused create left a table behind in the catalog")
# 8. And the dataset is still readable straight off its URI, which is what
# keeps the catalog optional.
direct = lance.dataset(location, storage_options=storage).count_rows()
check(direct == args.rows, f"direct read got {direct} rows, want {args.rows}")
print(f"direct read without the catalog -> {direct} rows")
print("PASS")
return 0
if __name__ == "__main__":
sys.exit(main())