mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-05 11:37:44 +01:00
* seaweed-vector: build and extend the vector index weed_lance_worker::vector_index builds an IVF_FLAT index in the metric the table was created with, sizes partitions from the row count, and extends an index with a new segment or a retrain; the core crate re-exports it and adds the probes a query spends. Tests cover coverage after build, append, retrain and compaction. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector-cli: index build and index optimize Logs go to stderr so stdout stays the JSON a caller parses. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * test/s3vectors: PERF_INDEX builds the index before the timed queries The cluster fixture hands out its catalog URL, the Makefile builds weed-vector-cli, and the perf suite builds the index after ingest and reports the build time and segment count. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * seaweed-vector: probe a tenth of the index's partitions, more under a filter A query that names no probe count gets one from the index's partition count. Lance searches at least that many partitions, and under a prefilter keeps going until it has k rows; without one it stops there. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: --nprobes overrides the probe policy Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * seaweed-vector: test that a build takes the table's metric Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * test/s3vectors: make clean removes weed-vector-cli Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * seaweed-vector: measure recall with vectors the table does not hold Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: keep the read cache's doc comment on with_read_cache Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-worker: lance_vector_index builds the index of an S3 Vectors table past a row threshold Detection keeps the tables a vector bucket created, with at least min_rows rows (50,000 by default) and no index on their vector column. Execution builds the IVF_FLAT index in the table's metric with sqrt(rows) partitions. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * seaweed-vector: optimize only the indices on the vector column Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * seaweed-vector: resolve the query metric with index::distance_metric Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * seaweed-vector: test the L2 metric under the name CreateIndex stores Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test/s3vectors: build the index through the admin server and the Lance worker VECTOR_WORKER=1 adds weed admin and weed-worker to the cluster. The new test sets the job's threshold, runs it through the plugin API, checks coverage and recall, and that a row written after the build is found. CI gains the worker leg for both volume servers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-worker: open a table inside a caller's Lance session Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: keep opened indices between queries in one session Every open used a fresh Lance session, so the index and metadata caches died with the request and each query loaded the IVF index again. --index-cache-mb sizes the shared cache, 512 MiB by default. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * test/s3vectors: PERF_CLUSTERS draws the perf suite's vectors around shared centres Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * test/s3vectors: the index test draws embedding-like vectors Independent Gaussian vectors have no locality for an IVF index to use, so recall through one only tracks the fraction of partitions probed; points around shared centres behave like embeddings. The test must also run before the one that stops the engine. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * seaweed-vector: test that compaction drops the deleted key from tied hits Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * seaweed-vector: test that an append-only optimize serves the new row Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test/s3vectors: record PERF_NOISE in the perf report config Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * seaweed-vector: a filtered query widens to four times the probes, not without bound With no upper bound Lance prepares every partition for the late search even when the minimum probes already hold k rows, and a filtered query through the index cost as much as the exact scan. A query that names its own probe count gets exactly that count. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * test/s3vectors: report recall through an index without asserting it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test/s3vectors: count a hit tying the k-th best score toward recall Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: lance_vector_index runs in the Lance lane Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-worker: check a table's index before counting its rows Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * test/s3vectors: make build and clean cover the worker leg's binaries Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * test/s3vectors: allow float32 rounding at the recall cutoff Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test/s3vectors: count a repeated hit key once toward recall Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test/s3vectors: credit cutoff ties only up to the slots the top-k leaves Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test/s3vectors: assert every filtered hit passes the filter, indexed or not Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * weed-vector: forget the shared session when a table was dropped and created again The session keys index lists and transactions by dataset version, and a table created again at the same location restarts its versions. An open that finds an older version than the newest seen at that URI, or the same version committed later, replaces the session and opens again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * weed-vector: test an open after another engine created the table again Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * weed-vector: replace the session in the same lock that records a recreated table A second open of the recreated table could see the new version recorded and the old session still shared, and keep the dropped table's index list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * weed-worker: skip a vector table whose rows cannot be counted Detection returned on the first table whose count_rows failed, so one unreadable table kept every other table from being proposed. It now skips that table like an open or index failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * grafana: panel for vector indexes the Lance worker built Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * seaweed-vector: size default probes from the largest index on the column A sharded build gives the column one index name per shard. The first name alone could be a small shard, and its probe count would then be spent on a larger one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * seaweed-vector: skip the probe lookup when a query is given no segments Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: open every table in its own Lance session again One session shared by every open keeps a table's index loaded between queries, but Lance keys what it caches by location and version, so a table dropped and created again at the same location could be served the old table's index list. Telling the two apart from version and commit time needs a map that only grows, a retry loop and clocks that agree across engines. A private session per open, as before, has no such state. Measured at 50k x 384 through an index, eight clients: 164 queries/s with the shared session, 137 without; one client is unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-vector: reject --nprobes 0 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * seaweed-vector: read the partition count from the opened index Lance's index statistics took 3.6 ms a query on a 200k-row table with 447 partitions. Opening the index and asking it takes 0.4 ms, and the scan that follows finds the index already open. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * ci: give the S3 Vectors worker legs the vendored protoc The worker workspace has no protoc of its own for lance's build scripts, so the worker legs failed at their first build. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-worker: give a vector index build an hour before the admin times it out The job set no execution timeout, so the admin's default of 90 seconds applied, and a build that trains on a large table would be reported as failed and retried while it was still running. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-worker: lance_vector_index proposes at most the admin's max_results Every qualifying table went into one message however many there were. The detector stops at the limit the request carries and sets has_more, as the Go detectors do. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-worker: a vector index job re-checks the table it was proposed for A table emptied since detection completes without a build, and one without a vector column fails instead of reporting it already indexed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-worker: a vector index run window as long as one build An attempt is capped at the end of the job type's run window plus the drain grace, and the default window is 30 minutes, so a build could be cut off before its own hour was up. The window is an hour too, as the Go iceberg handler sets both. 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>
384 lines
20 KiB
Python
384 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'}"], ()
|
|
credentials = ["--storage-option", "aws_access_key_id=test",
|
|
"--storage-option", "aws_secret_access_key=test"]
|
|
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, "--namespace", f"http://127.0.0.1:{lance}",
|
|
"--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")
|
|
}
|