You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
320 lines
13 KiB
320 lines
13 KiB
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import os
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from dataclasses import dataclass
|
|
|
|
|
|
def main() -> int:
|
|
config = AcceptanceConfig.from_env()
|
|
client = IronClient(config)
|
|
file_class = os.getenv("IRON_ACCEPTANCE_FILE_CLASS", "other")
|
|
|
|
print(f"[1/7] logging into Iron at {config.iron_base_url}")
|
|
client.login()
|
|
|
|
print(f"[2/7] ensuring backend {config.backend_name!r}")
|
|
backend = client.ensure_webdav_backend()
|
|
print(f" backend id: {backend['id']}")
|
|
|
|
print("[3/7] running backend health check")
|
|
check = client.check_backend(backend["id"])
|
|
if check["status"] != "healthy":
|
|
raise RuntimeError(f"backend health check failed: {json.dumps(check, ensure_ascii=False)}")
|
|
print(f" healthy: {check['detail']}")
|
|
|
|
original_policy = next(policy for policy in client.list_policies() if policy["file_class"] == file_class)
|
|
client.upsert_policy(
|
|
file_class,
|
|
require_local=True,
|
|
stable_replica_count=1,
|
|
opportunistic_replica_count=0,
|
|
preferred_backend_ids=[backend["id"]],
|
|
excluded_backend_ids=[],
|
|
max_non_local_size_bytes=None,
|
|
)
|
|
|
|
try:
|
|
print("[4/7] uploading acceptance file through Iron")
|
|
file_id = client.upload_text_file()
|
|
print(f" file id: {file_id}")
|
|
|
|
print("[5/7] waiting for local + OpenList replicas to become ready")
|
|
detail = client.wait_for_ready_replicas(file_id, backend["id"])
|
|
print(f" replicas ready: {detail['ready_replica_backend_ids']}")
|
|
|
|
print("[6/7] verifying download through Iron")
|
|
downloaded = client.download_file(file_id)
|
|
if downloaded != config.test_content.encode("utf-8"):
|
|
raise RuntimeError("downloaded bytes do not match uploaded content")
|
|
print(f" download ok: {len(downloaded)} bytes")
|
|
|
|
print("[7/7] verifying direct WebDAV read from OpenList")
|
|
webdav_replica = next(
|
|
replica for replica in detail["replicas"] if replica["backend_id"] == backend["id"] and replica["status"] == "ready"
|
|
)
|
|
dav_bytes = read_openlist_webdav(
|
|
endpoint_url=config.openlist_dav_url,
|
|
username=config.openlist_username,
|
|
password=config.openlist_password,
|
|
relative_path=f"{config.openlist_root_path}/{webdav_replica['storage_key']}",
|
|
)
|
|
if dav_bytes != config.test_content.encode("utf-8"):
|
|
raise RuntimeError("OpenList WebDAV bytes do not match uploaded content")
|
|
print(f" webdav ok: {webdav_replica['storage_key']}")
|
|
|
|
print("Acceptance passed.")
|
|
return 0
|
|
finally:
|
|
client.upsert_policy(
|
|
file_class,
|
|
require_local=bool(original_policy["require_local"]),
|
|
stable_replica_count=int(original_policy["stable_replica_count"]),
|
|
opportunistic_replica_count=int(original_policy["opportunistic_replica_count"]),
|
|
preferred_backend_ids=list(original_policy["preferred_backend_ids"]),
|
|
excluded_backend_ids=list(original_policy["excluded_backend_ids"]),
|
|
max_non_local_size_bytes=original_policy["max_non_local_size_bytes"],
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class AcceptanceConfig:
|
|
iron_base_url: str
|
|
iron_username: str
|
|
iron_password: str
|
|
openlist_dav_url: str
|
|
openlist_username: str
|
|
openlist_password: str
|
|
openlist_root_path: str
|
|
backend_name: str
|
|
directory_id: str
|
|
test_filename: str
|
|
test_content: str
|
|
reconcile_timeout_seconds: float
|
|
reconcile_poll_interval_seconds: float
|
|
http_timeout_seconds: float
|
|
|
|
@classmethod
|
|
def from_env(cls) -> "AcceptanceConfig":
|
|
default_filename = f"real-openlist-acceptance-{int(time.time())}.txt"
|
|
return cls(
|
|
iron_base_url=os.getenv("IRON_BASE_URL", "http://127.0.0.1:8000").rstrip("/"),
|
|
iron_username=os.getenv("IRON_USERNAME", "admin"),
|
|
iron_password=os.getenv("IRON_PASSWORD", "changeme-iron"),
|
|
openlist_dav_url=os.getenv("OPENLIST_DAV_URL", "http://127.0.0.1:5244/dav").rstrip("/"),
|
|
openlist_username=os.getenv("OPENLIST_USERNAME", "iron"),
|
|
openlist_password=os.getenv("OPENLIST_PASSWORD", "iron"),
|
|
openlist_root_path=os.getenv("OPENLIST_ROOT_PATH", "aliyun/iron").strip("/"),
|
|
backend_name=os.getenv("IRON_OPENLIST_BACKEND_NAME", "openlist-aliyun"),
|
|
directory_id=os.getenv("IRON_TEST_DIRECTORY_ID", "dir_root"),
|
|
test_filename=os.getenv("IRON_TEST_FILENAME", default_filename),
|
|
test_content=os.getenv("IRON_TEST_CONTENT", "real-openlist-acceptance\n"),
|
|
reconcile_timeout_seconds=float(os.getenv("IRON_RECONCILE_TIMEOUT_SECONDS", "45")),
|
|
reconcile_poll_interval_seconds=float(os.getenv("IRON_RECONCILE_POLL_INTERVAL_SECONDS", "2")),
|
|
http_timeout_seconds=float(os.getenv("IRON_HTTP_TIMEOUT_SECONDS", "30")),
|
|
)
|
|
|
|
|
|
class IronClient:
|
|
def __init__(self, config: AcceptanceConfig) -> None:
|
|
self.config = config
|
|
self.token: str | None = None
|
|
|
|
def login(self) -> None:
|
|
response = self._request(
|
|
"POST",
|
|
"/api/auth/login",
|
|
json_body={"username": self.config.iron_username, "password": self.config.iron_password},
|
|
)
|
|
self.token = response["access_token"]
|
|
|
|
def ensure_webdav_backend(self) -> dict[str, object]:
|
|
items = self.list_backends()
|
|
for backend in items:
|
|
if backend["name"] == self.config.backend_name:
|
|
return backend
|
|
payload = {
|
|
"name": self.config.backend_name,
|
|
"type": "webdav",
|
|
"stability_class": "stable",
|
|
"read_priority": 80,
|
|
"write_priority": 80,
|
|
"config": {
|
|
"endpoint_url": self.config.openlist_dav_url,
|
|
"username": self.config.openlist_username,
|
|
"password": self.config.openlist_password,
|
|
"root_path": self.config.openlist_root_path,
|
|
"verify_ssl": False,
|
|
},
|
|
}
|
|
response = self._request("POST", "/api/backends", json_body=payload)
|
|
return response["backend"]
|
|
|
|
def list_backends(self) -> list[dict[str, object]]:
|
|
return self._request("GET", "/api/backends")["items"]
|
|
|
|
def check_backend(self, backend_id: str) -> dict[str, object]:
|
|
return self._request("POST", f"/api/backends/{backend_id}/check")
|
|
|
|
def disable_backend(self, backend_id: str) -> dict[str, object]:
|
|
return self._request("POST", f"/api/backends/{backend_id}/disable")["backend"]
|
|
|
|
def enable_backend(self, backend_id: str) -> dict[str, object]:
|
|
return self._request("POST", f"/api/backends/{backend_id}/enable")["backend"]
|
|
|
|
def list_policies(self) -> list[dict[str, object]]:
|
|
return self._request("GET", "/api/policies/placement")["items"]
|
|
|
|
def list_jobs(self) -> list[dict[str, object]]:
|
|
return self._request("GET", "/api/jobs")["items"]
|
|
|
|
def upsert_policy(
|
|
self,
|
|
file_class: str,
|
|
*,
|
|
require_local: bool,
|
|
stable_replica_count: int,
|
|
opportunistic_replica_count: int,
|
|
preferred_backend_ids: list[str],
|
|
excluded_backend_ids: list[str],
|
|
max_non_local_size_bytes: int | None,
|
|
) -> dict[str, object]:
|
|
payload = {
|
|
"require_local": require_local,
|
|
"stable_replica_count": stable_replica_count,
|
|
"opportunistic_replica_count": opportunistic_replica_count,
|
|
"preferred_backend_ids": preferred_backend_ids,
|
|
"excluded_backend_ids": excluded_backend_ids,
|
|
"max_non_local_size_bytes": max_non_local_size_bytes,
|
|
}
|
|
return self._request("PUT", f"/api/policies/placement/{file_class}", json_body=payload)["policy"]
|
|
|
|
def upload_text_file(self) -> str:
|
|
return self.upload_bytes(
|
|
filename=self.config.test_filename,
|
|
content=self.config.test_content.encode("utf-8"),
|
|
directory_id=self.config.directory_id,
|
|
)
|
|
|
|
def upload_bytes(self, *, filename: str, content: bytes, directory_id: str) -> str:
|
|
metadata_value = base64.b64encode(filename.encode("utf-8")).decode("ascii")
|
|
headers = {
|
|
"Tus-Resumable": "1.0.0",
|
|
"Upload-Length": str(len(content)),
|
|
"Upload-Metadata": f"filename {metadata_value}",
|
|
}
|
|
create_response, create_headers = self._raw_request("POST", "/files", headers=headers)
|
|
_ = create_response
|
|
location = header_value(create_headers, "Location")
|
|
upload_id = location.rsplit("/", 1)[-1]
|
|
patch_headers = {
|
|
"Tus-Resumable": "1.0.0",
|
|
"Upload-Offset": "0",
|
|
"Content-Type": "application/offset+octet-stream",
|
|
}
|
|
self._raw_request("PATCH", f"/files/{upload_id}", headers=patch_headers, data=content)
|
|
finalize = self._request(
|
|
"POST",
|
|
f"/api/uploads/{upload_id}/finalize",
|
|
json_body={"directory_id": directory_id, "filename": filename},
|
|
)
|
|
return finalize["file"]["id"]
|
|
|
|
def wait_for_ready_replicas(self, file_id: str, backend_id: str) -> dict[str, object]:
|
|
return self.wait_for_ready_replica_set(file_id, {"bkd_local_default", backend_id})
|
|
|
|
def wait_for_ready_replica_set(self, file_id: str, backend_ids: set[str]) -> dict[str, object]:
|
|
deadline = time.time() + self.config.reconcile_timeout_seconds
|
|
detail: dict[str, object] = {}
|
|
while time.time() < deadline:
|
|
self.reconcile_file(file_id)
|
|
self.run_pending_jobs()
|
|
detail = self.file_detail(file_id)
|
|
ready_ids = set(detail["ready_replica_backend_ids"])
|
|
if backend_ids.issubset(ready_ids):
|
|
return detail
|
|
time.sleep(self.config.reconcile_poll_interval_seconds)
|
|
raise RuntimeError(f"timed out waiting for replicas: {json.dumps(detail, ensure_ascii=False)}")
|
|
|
|
def download_file(self, file_id: str) -> bytes:
|
|
body, _headers = self._raw_request("GET", f"/api/files/{file_id}/download")
|
|
return body
|
|
|
|
def file_detail(self, file_id: str) -> dict[str, object]:
|
|
return self._request("GET", f"/api/files/{file_id}")
|
|
|
|
def stream_file(self, file_id: str, *, range_header: str | None = None) -> tuple[bytes, dict[str, str]]:
|
|
headers = {"Range": range_header} if range_header else {}
|
|
return self._raw_request("GET", f"/api/files/{file_id}/stream", headers=headers)
|
|
|
|
def delete_file(self, file_id: str) -> dict[str, object]:
|
|
return self._request("DELETE", f"/api/files/{file_id}")
|
|
|
|
def restore_file(self, file_id: str) -> dict[str, object]:
|
|
return self._request("POST", f"/api/files/{file_id}/restore")
|
|
|
|
def list_recycle_bin(self) -> list[dict[str, object]]:
|
|
return self._request("GET", "/api/files/recycle-bin")["items"]
|
|
|
|
def reconcile_file(self, file_id: str) -> dict[str, object]:
|
|
return self._request("POST", f"/api/files/{file_id}/reconcile")
|
|
|
|
def run_pending_jobs(self) -> dict[str, object]:
|
|
return self._request("POST", "/api/jobs/run-pending")
|
|
|
|
def _request(self, method: str, path: str, json_body: dict[str, object] | None = None) -> dict[str, object]:
|
|
payload = None if json_body is None else json.dumps(json_body).encode("utf-8")
|
|
headers = {"Content-Type": "application/json"} if payload is not None else {}
|
|
body, _response_headers = self._raw_request(method, path, headers=headers, data=payload)
|
|
return json.loads(body.decode("utf-8")) if body else {}
|
|
|
|
def _raw_request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
headers: dict[str, str] | None = None,
|
|
data: bytes | None = None,
|
|
) -> tuple[bytes, dict[str, str]]:
|
|
merged_headers = dict(headers or {})
|
|
if self.token:
|
|
merged_headers["Authorization"] = f"Bearer {self.token}"
|
|
request = urllib.request.Request(
|
|
self.config.iron_base_url + path,
|
|
method=method,
|
|
data=data,
|
|
headers=merged_headers,
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=self.config.http_timeout_seconds) as response:
|
|
return response.read(), dict(response.headers.items())
|
|
except urllib.error.HTTPError as exc:
|
|
body = exc.read().decode("utf-8", errors="replace")
|
|
raise RuntimeError(f"{method} {path} failed with {exc.code}: {body}") from exc
|
|
|
|
|
|
def read_openlist_webdav(endpoint_url: str, username: str, password: str, relative_path: str) -> bytes:
|
|
token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii")
|
|
path = "/".join(urllib.parse.quote(part, safe="") for part in relative_path.strip("/").split("/") if part)
|
|
request = urllib.request.Request(
|
|
f"{endpoint_url.rstrip('/')}/{path}",
|
|
method="GET",
|
|
headers={"Authorization": f"Basic {token}"},
|
|
)
|
|
with urllib.request.urlopen(request, timeout=30) as response:
|
|
return response.read()
|
|
|
|
|
|
def header_value(headers: dict[str, str], name: str) -> str:
|
|
for key, value in headers.items():
|
|
if key.lower() == name.lower():
|
|
return value
|
|
raise KeyError(name)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|
|
|