mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-08 21:08:27 +01:00
* s3: encrypt volume data when the filer is configured to weed s3 read the filer's configuration at startup (buckets dir, filer group, masters, metrics, maxMB) but not its -encryptVolumeData, so a cluster that turned encryption on at the filer still had S3 uploads stored plain, while uploads through the filer itself were encrypted. The gateway now encrypts when either its own -encryptVolumeData or the filer's is set; weed-vector, weed-worker and weed-vector-cli already follow the filer's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * vector: weed-vector, weed-worker and the CLI take volume encryption from the filer They already encrypted chunks for the volume servers when the filer's configuration said so, or when their own --encryptVolumeData did. With weed s3 following the filer too, the filer's -encryptVolumeData is the one switch for every writer, so the three flags are removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * S3 Vectors tests: turn volume encryption on at the filer VECTOR_ENCRYPT_VOLUMES=1 set -encryptVolumeData on the gateway, the engine and the worker. It now sets it on the filer alone, so the leg checks that each of them follows the filer: the gateway's stored chunks carry a cipher key (with a gateway that ignores the filer, the leg fails on "the gateway stored a chunk without a cipher key"), and so do the engine's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * weed-worker README: volume encryption follows the filer Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK * s3: help text says the filer's -encryptVolumeData turns S3 encryption on Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * S3 Vectors tests: plain legs check the engine stored no cipher key Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
391 lines
20 KiB
Python
391 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 whose gateway has no S3 identities."""
|
|
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
|
|
# The cluster's filer signing key: the gateway's catalog authenticates
|
|
# the worker and the CLI by the token they derive from it.
|
|
filer_signing_key = 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 process_env(unset=()):
|
|
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_FILER_SIGNING_KEY=filer_signing_key,
|
|
**({"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)
|
|
return env
|
|
|
|
def start(name, args, unset=()):
|
|
log = stack.enter_context((directory / f"{name}.log").open("w"))
|
|
process = subprocess.Popen(args, cwd=directory, stdout=log, stderr=log, env=process_env(unset))
|
|
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")
|
|
# VECTOR_ENCRYPT_VOLUMES=1 turns on the filer's volume encryption, which
|
|
# the gateway, the engine and the worker all follow: every chunk they
|
|
# store is encrypted, and direct reads must undo it.
|
|
encrypt = ["-encryptVolumeData"] if os.environ.get("VECTOR_ENCRYPT_VOLUMES") else []
|
|
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'}", *encrypt])
|
|
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.
|
|
if identities:
|
|
config, unset = [f"-config={HERE / 's3.json'}"], ()
|
|
else:
|
|
config, unset = [], ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY")
|
|
start("s3", [weed, "-v=1", "s3", f"-filer={filer_addr}", *config,
|
|
"-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)
|
|
# The engine keeps tables on the filers and volume servers itself; it
|
|
# has no S3 credentials to be given, and encrypts its chunks for the
|
|
# volume servers as the gateway does.
|
|
start("vector", [engine, "--masters", master_addr, "--listen", f"127.0.0.1:{vector}"])
|
|
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",
|
|
"--masters", master_addr])
|
|
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, "filer": filer,
|
|
"masters": master_addr, "filer_signing_key": filer_signing_key,
|
|
"catalog": f"http://127.0.0.1:{lance}", "admin": admin_url, "env": process_env()}
|
|
|
|
|
|
@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")
|
|
}
|