mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-07 20:47:46 +01:00
* lance catalog: in-process DescribeTable for trusted callers DescribeTable and DescribeTableInternal resolve a table's location and storage options without the HTTP layer; the internal form runs the lookup through a trusted manager so the caller's catalog grants do not gate a resolution its own permission already authorized. Same code as the Enterprise repository. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * vector.proto: Table carries its location and storage options The gateway resolves the table it addresses, so the engine can open it without a describe round trip of its own. Both fields are optional; an empty location means the engine looks the table up as before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * lance catalog: test the in-process describe Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * s3vectors: resolve the table before calling the engine The gateway describes the table in process, through the trusted catalog manager, and sends its location and storage options with every engine call. Storage options are vended for the caller's principal, as the Enterprise router does for queries. A catalog failure answers with the code the engine would have given. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: open a table at the location the gateway sent A request whose table carries a location is opened there, with the storage options the gateway vended over the engine's fallback; the catalog is described only for a table that comes without one, as from an older gateway. dataset::open_at and dataset::drop_at take a description the caller already holds. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * s3vectors e2e: run the engine without a catalog VECTOR_ENGINE_NO_CATALOG=1 gives the engine an unreachable catalog URL; every CI leg sets it, so the suite proves the gateway resolves each table it sends. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * lance catalog: an internal describe may write a vector index With credential vending on, the options vended to the engine followed the caller's s3tables grants, which never allow writing the vector namespace, so every write the gateway sent would have run with read-only credentials and reads would have lost their version lease. The engine is that namespace's writer and the caller's vector permission authorized the operation, so an internal describe of a vector table vends write-capable options; other namespaces keep the UpdateTable check. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * s3: resolve tables for the engine only while the catalog runs With -port.lance=0 the gateway has no catalog to resolve through; the engine then keeps looking tables up itself, as before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * lance catalog: a deregistered table describes as NoSuchTable So the gateway answers NotFoundException for it, not InternalFailure. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: trust a sent location only for a colocated vector index A caller that reaches the engine could otherwise name any storage the engine's credentials cover. A location is taken from the request only when the id names a vector index and the location is its colocated path, which the engine writes already; anything else is described through the catalog as before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: scope the read cache by storage endpoint The cache was keyed by bucket and path alone, so the same path reached through another endpoint would have shared entries. Each open wraps the store with the cache scoped to the endpoint its options name. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: keep a configured endpoint over one a request names A caller that reaches the engine could otherwise send its fallback credentials to an endpoint of the caller's choosing. When the engine's --storage-option names an aws_endpoint, a table whose request names another one is described through the catalog instead. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: use request options only where credentials cannot leave A request's storage options are taken only when they hold the keys a gateway's describe produces, so a proxy or an endpoint alias cannot route the engine's credentials elsewhere, and an endpoint only when it is the configured one or the engine holds no credentials to send. Anything else sends the table to the catalog. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * s3vectors e2e: give the engine its endpoint with its credentials Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: a drop checks the request's options like an open Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: an unpinned endpoint needs the request to sign for itself Without configured credentials the S3 store still signs with the AWS default chain, so an endpoint from a request is taken only when the request skips signing or brings its own credentials. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: a trailing slash does not unpin the endpoint, and say so A configured aws_endpoint written with a trailing slash matched nothing the gateway sends, so every table fell back to the catalog in silence. The comparison ignores the slash, and a fallback for a resolved table is logged at debug. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: resolved by the gateway, or refused The engine no longer has a catalog: --namespace and --filer-signing-key are gone, a request whose table names no location is InvalidArgument, and one whose location is not the index's own or whose storage options the engine's credentials must not be used with is PermissionDenied. A drop removes the colocated path it was given. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * s3: the catalog resolves vector tables whether or not its port is served newLanceServer builds the Lance catalog for the S3 Vectors API; the listener on -port.lance is optional. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * s3vectors tests: start the engine without a catalog Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * s3: the aux listener test builds the catalog before serving it Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: an unpinned endpoint takes only credentials the request signs with alone Lance fills an option a request leaves out from the engine's environment, under any of object_store's names for it (TOKEN, SESSION_TOKEN, ...), and asks the AWS default chain when the key pair is incomplete. A request that named its own endpoint with only an access key id, or a key pair while the engine's environment held a session token, would have sent the engine's credentials there. Such a request now needs a full key pair, and its own session token whenever the environment would supply one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * weed-vector: accept --namespace and --filer-signing-key, ignored Both flags shipped in 4.52 and are documented, so dropping them made an existing command line exit at startup with an unexpected-argument error. They are kept hidden and ignored, with a warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3vectors design: the engine is started without a catalog Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * weed-vector: the trust check takes the environment it reads The options test read the process environment through signs_alone, so a host exporting AWS_SESSION_TOKEN or TOKEN failed it. The caller now passes the variable names in; describe passes the engine's own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * weed-vector: log both endpoints when a request's options are refused The gateway turns the PermissionDenied into a bare AccessDenied and logs nothing, so a pinned aws_endpoint written in another form than the one the gateway sends (host case, a default port, an IP for a name) failed every call with no trace of which value differed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3: the Lance catalog sets the credential role whether or not Iceberg runs -iceberg.credentialRole reached the S3 server only while the Iceberg listener started, so with -port.iceberg=0 the Lance catalog installed its vendor but vending stayed off, and the vector engine got no credentials for the tables the gateway resolves. Both catalogs now set the role through one helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3: set the credential role once, before either catalog serves Both catalogs wrote the role as they were built; the second write landed while the first was already serving requests that read it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
385 lines
20 KiB
Python
385 lines
20 KiB
Python
import base64
|
|
import contextlib
|
|
import gzip
|
|
import hashlib
|
|
import hmac
|
|
import http.client
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import socket
|
|
import ssl
|
|
import subprocess
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
import uuid
|
|
import zlib
|
|
|
|
import boto3
|
|
from botocore.config import Config
|
|
import pytest
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
|
|
|
|
def combinations():
|
|
volumes = os.environ.get("VOLUME_SERVER_IMPL", "go,rust").split(",")
|
|
default_modes = "single,distributed" if (HERE / "../../rust/sw-vector-distributed/Cargo.toml").is_file() else "single"
|
|
modes = os.environ.get("VECTOR_QUERY_MODE", default_modes).split(",")
|
|
if not set(volumes) <= {"go", "rust"} or not set(modes) <= {"single", "distributed"}:
|
|
raise ValueError("invalid VOLUME_SERVER_IMPL or VECTOR_QUERY_MODE")
|
|
return [(volume, mode) for volume in volumes for mode in modes]
|
|
|
|
|
|
def binary(variable, name):
|
|
path = Path(os.environ.get(variable, str(HERE / name))).resolve()
|
|
if not path.is_file() or not os.access(path, os.X_OK):
|
|
pytest.fail(f"Missing executable {path}; run make build or set {variable}")
|
|
return str(path)
|
|
|
|
|
|
def wait_for(check, processes, message):
|
|
deadline = time.monotonic() + 60
|
|
while time.monotonic() < deadline:
|
|
for name, process in processes.items():
|
|
if process.poll() is not None:
|
|
pytest.fail(f"{name} exited with {process.returncode}; {message}")
|
|
try:
|
|
if check():
|
|
return
|
|
except (OSError, urllib.error.URLError):
|
|
pass
|
|
time.sleep(0.2)
|
|
pytest.fail(message)
|
|
|
|
|
|
def listening(port):
|
|
with socket.create_connection(("127.0.0.1", port), timeout=0.5):
|
|
return True
|
|
|
|
|
|
def http_ready(url):
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=0.5):
|
|
return True
|
|
except urllib.error.HTTPError:
|
|
return True
|
|
|
|
|
|
def stop(process):
|
|
if process.poll() is None:
|
|
process.terminate()
|
|
try:
|
|
process.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait(timeout=10)
|
|
|
|
|
|
def pytest_configure(config):
|
|
config.addinivalue_line("markers", "worker: needs the admin server and the Lance maintenance worker")
|
|
|
|
|
|
def pytest_collection_modifyitems(config, items):
|
|
# Deselected rather than skipped: CI rejects a leg that reports skips.
|
|
if os.environ.get("VECTOR_WORKER"):
|
|
return
|
|
skipped = [item for item in items if item.get_closest_marker("worker")]
|
|
if skipped:
|
|
config.hook.pytest_deselected(items=skipped)
|
|
items[:] = [item for item in items if item not in skipped]
|
|
|
|
|
|
@pytest.fixture(scope="module", params=combinations(), ids=lambda p: "-".join(p))
|
|
def cluster(request, tmp_path_factory):
|
|
yield from run_cluster(request.param, tmp_path_factory, identities=True)
|
|
|
|
|
|
@pytest.fixture(scope="module", params=combinations(), ids=lambda p: "-".join(p))
|
|
def open_cluster(request, tmp_path_factory):
|
|
"""A cluster with no S3 identities, and an engine given no credentials."""
|
|
yield from run_cluster(request.param, tmp_path_factory, identities=False)
|
|
|
|
|
|
def write_tls(directory):
|
|
"""A CA and one certificate for localhost. Volume servers serve HTTPS and
|
|
every gRPC listener (master, volume, filer, gateway, engine) serves TLS,
|
|
all requiring client certificates, and every client presents the same one."""
|
|
def openssl(*args):
|
|
subprocess.run(["openssl", *args], check=True, capture_output=True, cwd=directory)
|
|
openssl("req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", "ca.key", "-out", "ca.pem",
|
|
"-subj", "/CN=s3vectors test CA", "-days", "2",
|
|
"-addext", "basicConstraints=critical,CA:TRUE", "-addext", "keyUsage=critical,keyCertSign,cRLSign")
|
|
openssl("req", "-newkey", "rsa:2048", "-nodes", "-keyout", "key.pem", "-out", "req.csr", "-subj", "/CN=localhost")
|
|
(directory / "ext.cnf").write_text(
|
|
"subjectAltName=DNS:localhost,IP:127.0.0.1\nextendedKeyUsage=serverAuth,clientAuth\nbasicConstraints=CA:FALSE\n")
|
|
openssl("x509", "-req", "-in", "req.csr", "-CA", "ca.pem", "-CAkey", "ca.key", "-CAcreateserial",
|
|
"-out", "cert.pem", "-days", "2", "-extfile", "ext.cnf")
|
|
# A second identity the same CA signed, which the engine's allowlist
|
|
# must keep out.
|
|
openssl("req", "-newkey", "rsa:2048", "-nodes", "-keyout", "outsider.key", "-out", "outsider.csr", "-subj", "/CN=outsider")
|
|
openssl("x509", "-req", "-in", "outsider.csr", "-CA", "ca.pem", "-CAkey", "ca.key", "-CAcreateserial",
|
|
"-out", "outsider.pem", "-days", "2", "-extfile", "ext.cnf")
|
|
cert, key, ca = (f'{key} = {json.dumps(str(directory / name), ensure_ascii=False)}\n' for key, name in (("cert", "cert.pem"), ("key", "key.pem"), ("ca", "ca.pem")))
|
|
pem = cert + key + ca
|
|
grpc = "".join(f"[grpc.{component}]\n{cert}{key}\n" for component in ("master", "volume", "filer", "s3"))
|
|
# Clients (the gateway, the engine) accept only servers named localhost;
|
|
# the engine serves only clients named localhost.
|
|
grpc += f"[grpc.client]\n{cert}{key}allowed_server_commonNames = \"localhost\"\n"
|
|
grpc += f"[grpc.vector]\n{cert}{key}allowed_commonNames = \"localhost\"\n"
|
|
(directory / "security.toml").write_text(f"[https.volume]\n{pem}\n[https.client]\nenabled = true\n{pem}\n[grpc]\n{ca}\n{grpc}")
|
|
|
|
|
|
def probe_grpc_tls(name, port, directory, outsider=False):
|
|
"""A TLS gRPC listener must refuse plaintext HTTP/2 and a connection
|
|
without the client certificate, and answer the HTTP/2 preface with it.
|
|
With `outsider`, a certificate the CA signed for a name outside the
|
|
listener's allowed_commonNames must be refused too."""
|
|
preface = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
|
|
with socket.create_connection(("127.0.0.1", port), timeout=5) as sock:
|
|
sock.sendall(preface)
|
|
with contextlib.suppress(ConnectionError, TimeoutError):
|
|
answer = sock.recv(16)
|
|
# Nothing, or a TLS alert record; a plaintext server sends SETTINGS.
|
|
assert not answer or answer[0] == 0x15, f"{name} gRPC answered plaintext HTTP/2: {answer!r}"
|
|
context = ssl.create_default_context(cafile=directory / "ca.pem")
|
|
context.set_alpn_protocols(["h2"])
|
|
with contextlib.suppress(ConnectionError, TimeoutError, ssl.SSLError):
|
|
with socket.create_connection(("127.0.0.1", port), timeout=5) as sock:
|
|
with context.wrap_socket(sock, server_hostname="localhost") as tls:
|
|
tls.sendall(preface)
|
|
assert not tls.recv(16), f"{name} gRPC served a client without a certificate"
|
|
if outsider:
|
|
other = ssl.create_default_context(cafile=directory / "ca.pem")
|
|
other.set_alpn_protocols(["h2"])
|
|
other.load_cert_chain(directory / "outsider.pem", directory / "outsider.key")
|
|
with contextlib.suppress(ConnectionError, TimeoutError, ssl.SSLError):
|
|
with socket.create_connection(("127.0.0.1", port), timeout=5) as sock:
|
|
with other.wrap_socket(sock, server_hostname="localhost") as tls:
|
|
tls.sendall(preface)
|
|
assert not tls.recv(16), f"{name} gRPC served a certificate outside allowed_commonNames"
|
|
context.load_cert_chain(directory / "cert.pem", directory / "key.pem")
|
|
with socket.create_connection(("127.0.0.1", port), timeout=5) as sock:
|
|
with context.wrap_socket(sock, server_hostname="localhost") as tls:
|
|
tls.sendall(preface)
|
|
header = b""
|
|
while len(header) < 9 and (chunk := tls.recv(9 - len(header))):
|
|
header += chunk
|
|
# The first server frame is SETTINGS (type 4).
|
|
assert len(header) == 9 and header[3] == 4, \
|
|
f"{name} gRPC did not answer the HTTP/2 preface over TLS: {header!r}"
|
|
|
|
|
|
def read_token(key, fid):
|
|
def encode(data):
|
|
return base64.urlsafe_b64encode(data).rstrip(b"=")
|
|
signed = encode(b'{"alg":"HS256","typ":"JWT"}') + b"." + encode(json.dumps({"fid": fid}).encode())
|
|
return (signed + b"." + encode(hmac.new(key.encode(), signed, hashlib.sha256).digest())).decode()
|
|
|
|
|
|
def probe_hardening(endpoint, filer, vol, directory, read_jwt_key):
|
|
"""The hardened switches must be enforced, not only configured: a TLS
|
|
volume server refuses plain HTTP and a connection without the client
|
|
certificate, a volume server under a read key refuses an unsigned
|
|
read of a real chunk, and an encrypted chunk is stored as ciphertext."""
|
|
scheme = "https" if os.environ.get("VECTOR_TLS") else "http"
|
|
context = None
|
|
if scheme == "https":
|
|
context = ssl.create_default_context(cafile=directory / "ca.pem")
|
|
for url, kwargs in ((f"http://127.0.0.1:{vol}/status", {}),
|
|
(f"https://127.0.0.1:{vol}/status", {"context": context})):
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=5, **kwargs) as response:
|
|
assert response.status != 200, f"{url} answered without the client certificate"
|
|
# Go answers plaintext with HTTP 400, Rust with a TLS alert.
|
|
except (urllib.error.URLError, ConnectionError, ssl.SSLError, http.client.HTTPException):
|
|
pass
|
|
context.load_cert_chain(directory / "cert.pem", directory / "key.pem")
|
|
with urllib.request.urlopen(f"https://127.0.0.1:{vol}/status", timeout=5, context=context) as response:
|
|
assert response.status == 200
|
|
encrypted = os.environ.get("VECTOR_ENCRYPT_VOLUMES")
|
|
if not (read_jwt_key or encrypted):
|
|
return
|
|
s3 = boto3.client("s3", endpoint_url=endpoint, aws_access_key_id="test", aws_secret_access_key="test",
|
|
region_name="us-east-1", config=Config(s3={"addressing_style": "path"}))
|
|
bucket = "hardening-" + uuid.uuid4().hex[:12]
|
|
body = b"x" * 1024
|
|
s3.create_bucket(Bucket=bucket)
|
|
try:
|
|
s3.put_object(Bucket=bucket, Key="chunk", Body=body)
|
|
with urllib.request.urlopen(f"http://127.0.0.1:{filer}/buckets/{bucket}/chunk?metadata=true", timeout=5) as response:
|
|
chunk = json.load(response)["chunks"][0]
|
|
url = f"{scheme}://127.0.0.1:{vol}/{chunk['file_id']}"
|
|
headers = {}
|
|
if read_jwt_key:
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=5, context=context):
|
|
raise AssertionError("the volume server served an unsigned read")
|
|
except urllib.error.HTTPError as error:
|
|
assert error.code == 401, error
|
|
headers["Authorization"] = "Bearer " + read_token(read_jwt_key, chunk["file_id"])
|
|
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=5, context=context) as response:
|
|
stored = response.read()
|
|
if encrypted:
|
|
assert chunk.get("cipher_key"), "the gateway stored a chunk without a cipher key"
|
|
assert stored and stored != body, "the volume server holds the chunk in plaintext or not at all"
|
|
with contextlib.suppress(OSError, EOFError, zlib.error):
|
|
assert gzip.decompress(stored) != body, "the volume server holds the chunk gzipped, not encrypted"
|
|
else:
|
|
assert stored == body, "the signed read did not return the chunk"
|
|
finally:
|
|
s3.delete_object(Bucket=bucket, Key="chunk")
|
|
s3.delete_bucket(Bucket=bucket)
|
|
|
|
|
|
def run_cluster(param, tmp_path_factory, identities):
|
|
volume, mode = param
|
|
directory = tmp_path_factory.mktemp(f"vectors-{volume}-{mode}" + ("" if identities else "-open"))
|
|
print(f"\nVector integration logs: {directory}")
|
|
weed = binary("WEED_BINARY", "weed")
|
|
engine = binary("VECTOR_BINARY", "weed-vector")
|
|
# VECTOR_WORKER=1 adds the admin server and the Lance maintenance worker,
|
|
# for the tests that build vector indexes through a plugin job.
|
|
lance_worker = binary("LANCE_WORKER_BINARY", "weed-worker") if os.environ.get("VECTOR_WORKER") else None
|
|
worker = binary("VECTOR_WORKER_BINARY", "weed-vector-worker") if mode == "distributed" else None
|
|
volume_binary = binary("RUST_VOLUME_SERVER_BINARY", "weed-volume") if volume == "rust" else weed
|
|
processes = {}
|
|
with contextlib.ExitStack() as stack:
|
|
with contextlib.ExitStack() as reservations:
|
|
ports = []
|
|
for _ in range(14):
|
|
sock = reservations.enter_context(socket.socket())
|
|
sock.bind(("127.0.0.1", 0))
|
|
ports.append(sock.getsockname()[1])
|
|
(master, master_grpc, vol, vol_grpc, filer, filer_grpc, s3, s3_grpc, lance, vector, worker1, worker2,
|
|
admin, admin_grpc) = ports
|
|
master_addr = f"127.0.0.1:{master}.{master_grpc}"
|
|
filer_addr = f"127.0.0.1:{filer}.{filer_grpc}"
|
|
|
|
# VECTOR_READ_JWT=1 makes volume reads need a JWT; every weed process
|
|
# and the workers sign with the key.
|
|
read_jwt_key = os.environ.get("VECTOR_READ_JWT") and uuid.uuid4().hex
|
|
# VECTOR_TLS=1 puts the volume servers behind HTTPS with client
|
|
# certificates; every process finds security.toml in its cwd.
|
|
if os.environ.get("VECTOR_TLS"):
|
|
write_tls(directory)
|
|
|
|
def start(name, args, unset=()):
|
|
log = stack.enter_context((directory / f"{name}.log").open("w"))
|
|
env = dict(os.environ, AWS_ACCESS_KEY_ID="test", AWS_SECRET_ACCESS_KEY="test",
|
|
AWS_DEFAULT_REGION="us-east-1", AWS_EC2_METADATA_DISABLED="true",
|
|
WEED_S3_SSE_KEY="s3vectors-test",
|
|
**({"WEED_JWT_SIGNING_READ_KEY": read_jwt_key} if read_jwt_key else {}))
|
|
env.pop("WEED_S3_SSE_KEK", None)
|
|
if not read_jwt_key:
|
|
env.pop("WEED_JWT_SIGNING_READ_KEY", None)
|
|
for key in unset:
|
|
env.pop(key)
|
|
process = subprocess.Popen(args, cwd=directory, stdout=log, stderr=log, env=env)
|
|
processes[name] = process
|
|
stack.callback(stop, process)
|
|
return process
|
|
|
|
for name in ("master", "volume", "filer"):
|
|
(directory / name).mkdir()
|
|
start("master", [weed, "-v=1", "master", "-ip=127.0.0.1", "-ip.bind=127.0.0.1",
|
|
f"-port={master}", f"-port.grpc={master_grpc}",
|
|
f"-mdir={directory / 'master'}", "-peers=none", "-volumeSizeLimitMB=32"])
|
|
wait_for(lambda: http_ready(f"http://127.0.0.1:{master}/dir/status"), processes, "master not ready")
|
|
prefix = [volume_binary] if volume == "rust" else [weed, "volume"]
|
|
flag = "--" if volume == "rust" else "-"
|
|
start("volume", prefix + [f"{flag}{key}={value}" for key, value in {
|
|
"master": master_addr, "ip": "127.0.0.1", "ip.bind": "127.0.0.1",
|
|
"port": vol, "port.grpc": vol_grpc, "dir": directory / "volume",
|
|
"max": 64, "preStopSeconds": 0,
|
|
}.items()])
|
|
wait_for(lambda: listening(vol_grpc), processes, "volume not ready")
|
|
start("filer", [weed, "filer", f"-master={master_addr}", "-ip=127.0.0.1",
|
|
"-ip.bind=127.0.0.1", f"-port={filer}", f"-port.grpc={filer_grpc}",
|
|
f"-defaultStoreDir={directory / 'filer'}"])
|
|
wait_for(lambda: listening(filer_grpc), processes, "filer not ready")
|
|
endpoint = f"http://127.0.0.1:{s3}"
|
|
# weed s3 turns AWS_ACCESS_KEY_ID into an admin identity, and the engine
|
|
# would find it as a credential.
|
|
if identities:
|
|
config, unset = [f"-config={HERE / 's3.json'}"], ()
|
|
# The engine uses its credentials only with the endpoint it is given.
|
|
credentials = ["--storage-option", "aws_access_key_id=test",
|
|
"--storage-option", "aws_secret_access_key=test",
|
|
"--storage-option", f"aws_endpoint={endpoint}"]
|
|
else:
|
|
config, unset, credentials = [], ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"), []
|
|
# VECTOR_ENCRYPT_VOLUMES=1 has the gateway encrypt every chunk it
|
|
# stores, which direct reads must undo.
|
|
encrypt = ["-encryptVolumeData"] if os.environ.get("VECTOR_ENCRYPT_VOLUMES") else []
|
|
start("s3", [weed, "-v=1", "s3", f"-filer={filer_addr}", *config, *encrypt,
|
|
"-ip=127.0.0.1", "-ip.bind=127.0.0.1", f"-port={s3}",
|
|
f"-port.grpc={s3_grpc}", "-port.iceberg=0", f"-port.lance={lance}"], unset)
|
|
wait_for(lambda: http_ready(f"http://127.0.0.1:{lance}/v1/namespace/root/list"), processes, "catalog not ready")
|
|
wait_for(lambda: listening(s3), processes, "s3 not ready")
|
|
if os.environ.get("VECTOR_TLS") or read_jwt_key or encrypt:
|
|
probe_hardening(endpoint, filer, vol, directory, read_jwt_key)
|
|
start("vector", [engine, "--masters", master_addr, "--listen", f"127.0.0.1:{vector}", *credentials], unset)
|
|
wait_for(lambda: listening(vector), processes, "vector engine not ready")
|
|
if os.environ.get("VECTOR_TLS"):
|
|
wait_for(lambda: listening(s3_grpc), processes, "s3 gRPC not ready")
|
|
for name, port in (("master", master_grpc), ("volume", vol_grpc), ("filer", filer_grpc),
|
|
("s3", s3_grpc)):
|
|
probe_grpc_tls(name, port, directory)
|
|
probe_grpc_tls("engine", vector, directory, outsider=True)
|
|
|
|
wait_for(
|
|
lambda: f"Cluster: vector node 127.0.0.1:{vector} added to group ''"
|
|
in (directory / "master.log").read_text(),
|
|
processes,
|
|
"vector engine never registered with the master",
|
|
)
|
|
|
|
if worker:
|
|
for i, port in enumerate((worker1, worker2)):
|
|
start(f"worker-{i}", [worker, "--masters", master_addr, "--listen", f"127.0.0.1:{port}"])
|
|
wait_for(lambda: listening(port), processes, "vector worker not ready")
|
|
wait_for(
|
|
lambda: all(
|
|
f"S3 Vectors: discovered search worker 127.0.0.1:{port}"
|
|
in (directory / "s3.log").read_text()
|
|
for port in (worker1, worker2)
|
|
),
|
|
processes,
|
|
"gateway did not discover both search workers",
|
|
)
|
|
admin_url = None
|
|
if lance_worker:
|
|
(directory / "admin").mkdir()
|
|
admin_url = f"http://127.0.0.1:{admin}"
|
|
start("admin", [weed, "admin", "-ip=127.0.0.1", f"-port={admin}", f"-port.grpc={admin_grpc}",
|
|
"-port.legacy=0", f"-master={master_addr}", f"-dataDir={directory / 'admin'}"])
|
|
wait_for(lambda: http_ready(f"{admin_url}/api/plugin/job-types"), processes, "admin not ready")
|
|
start("lance-worker", [lance_worker, "--admin", f"127.0.0.1:{admin}.{admin_grpc}",
|
|
"--namespace", f"http://127.0.0.1:{lance}", "--id", "vectors-test-worker",
|
|
"--access-key", "test", "--secret-key", "test"])
|
|
wait_for(lambda: "lance_vector_index" in
|
|
[jt["job_type"] for jt in json.load(urllib.request.urlopen(f"{admin_url}/api/plugin/job-types", timeout=2))],
|
|
processes, "the Lance worker never registered with admin")
|
|
|
|
yield {"endpoint": endpoint, "mode": mode, "processes": processes, "directory": directory,
|
|
"catalog": f"http://127.0.0.1:{lance}", "admin": admin_url}
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def vectors(cluster):
|
|
return boto3.client("s3vectors", endpoint_url=cluster["endpoint"],
|
|
aws_access_key_id="test", aws_secret_access_key="test", region_name="us-east-1",
|
|
config=Config(connect_timeout=3, read_timeout=20, retries={"max_attempts": 0}))
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def vector_clients(vectors):
|
|
return {
|
|
name: boto3.client("s3vectors", endpoint_url=vectors.meta.endpoint_url,
|
|
aws_access_key_id=name, aws_secret_access_key=name + "-secret",
|
|
region_name="us-east-1", config=vectors.meta.config)
|
|
for name in ("reader", "writer")
|
|
}
|