- 平台治理: 租户用户权限层次、资源ACL、审批中心与审批模板、访问申请 - 存储: MinIO 存储进度迁移、对象存储安全加固与测试 - 计算: GPU 资源预留、compute 轮询与同步增强 - 权限: permission v2 迁移、权限安全验收测试 - 日志: 后端运行日志中文说明、操作日志整合 - 数据处理/评测: 数据转换与模型评测优化 Co-Authored-By: Claude <noreply@anthropic.com>
142 lines
5.3 KiB
Python
142 lines
5.3 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import timedelta
|
|
from functools import lru_cache
|
|
from io import BytesIO
|
|
from collections.abc import Iterator
|
|
from typing import Any
|
|
|
|
from minio import Minio
|
|
from minio.error import S3Error
|
|
import urllib3
|
|
|
|
from app.core.config import get_settings
|
|
|
|
|
|
class ObjectStorageError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class MinioObjectStorage:
|
|
def __init__(self) -> None:
|
|
settings = get_settings()
|
|
endpoint = settings.minio_endpoint.replace("http://", "").replace("https://", "").rstrip("/")
|
|
# MinIO outages must fail fast; higher-level workflows own the retry
|
|
# policy and should not wait through urllib3's default retry chain.
|
|
http_client = urllib3.PoolManager(
|
|
cert_reqs="CERT_REQUIRED" if settings.minio_secure else "CERT_NONE",
|
|
timeout=urllib3.Timeout(connect=2.0, read=10.0),
|
|
retries=False,
|
|
)
|
|
self.client = Minio(
|
|
endpoint,
|
|
access_key=settings.minio_access_key,
|
|
secret_key=settings.minio_secret_key,
|
|
secure=settings.minio_secure,
|
|
http_client=http_client,
|
|
)
|
|
self.bucket = settings.minio_bucket
|
|
|
|
def _ensure_enabled(self) -> None:
|
|
if not get_settings().minio_enabled:
|
|
raise ObjectStorageError("MinIO object storage is disabled")
|
|
|
|
def ensure_bucket(self) -> None:
|
|
self._ensure_enabled()
|
|
try:
|
|
if not self.client.bucket_exists(self.bucket):
|
|
self.client.make_bucket(self.bucket)
|
|
except Exception as exc: # noqa: BLE001 - normalize network/client failures
|
|
raise ObjectStorageError(str(exc)) from exc
|
|
|
|
def presigned_put(self, object_key: str, expires_seconds: int = 3600) -> str:
|
|
self._ensure_enabled()
|
|
self.ensure_bucket()
|
|
return self.client.presigned_put_object(self.bucket, object_key, expires=timedelta(seconds=expires_seconds))
|
|
|
|
def presigned_get(self, object_key: str, expires_seconds: int = 3600) -> str:
|
|
self._ensure_enabled()
|
|
self.ensure_bucket()
|
|
return self.client.presigned_get_object(self.bucket, object_key, expires=timedelta(seconds=expires_seconds))
|
|
|
|
def stat(self, object_key: str) -> dict[str, Any]:
|
|
self._ensure_enabled()
|
|
self.ensure_bucket()
|
|
try:
|
|
result = self.client.stat_object(self.bucket, object_key)
|
|
return {"object_key": object_key, "byte_size": result.size, "etag": result.etag, "last_modified": result.last_modified.isoformat() if result.last_modified else None}
|
|
except S3Error as exc:
|
|
raise ObjectStorageError(str(exc)) from exc
|
|
|
|
def get_bytes(self, object_key: str) -> bytes:
|
|
"""Read an object through the backend for small API responses and workers."""
|
|
self._ensure_enabled()
|
|
self.ensure_bucket()
|
|
response = None
|
|
try:
|
|
response = self.client.get_object(self.bucket, object_key)
|
|
return response.read()
|
|
except S3Error as exc:
|
|
raise ObjectStorageError(str(exc)) from exc
|
|
finally:
|
|
if response is not None:
|
|
response.close()
|
|
response.release_conn()
|
|
|
|
def iter_bytes(self, object_key: str, chunk_size: int = 256 * 1024) -> Iterator[bytes]:
|
|
"""Stream an object without loading the complete file into memory."""
|
|
self._ensure_enabled()
|
|
self.ensure_bucket()
|
|
response = None
|
|
try:
|
|
response = self.client.get_object(self.bucket, object_key)
|
|
while True:
|
|
chunk = response.read(chunk_size)
|
|
if not chunk:
|
|
break
|
|
yield chunk
|
|
except S3Error as exc:
|
|
raise ObjectStorageError(str(exc)) from exc
|
|
finally:
|
|
if response is not None:
|
|
response.close()
|
|
response.release_conn()
|
|
|
|
def list_objects(self, prefix: str) -> list[dict[str, Any]]:
|
|
self._ensure_enabled()
|
|
self.ensure_bucket()
|
|
try:
|
|
return [
|
|
{
|
|
"object_key": item.object_name,
|
|
"byte_size": item.size or 0,
|
|
"etag": item.etag,
|
|
"last_modified": item.last_modified.isoformat() if item.last_modified else None,
|
|
}
|
|
for item in self.client.list_objects(self.bucket, prefix=prefix, recursive=True)
|
|
]
|
|
except S3Error as exc:
|
|
raise ObjectStorageError(str(exc)) from exc
|
|
|
|
def delete(self, object_key: str) -> None:
|
|
self._ensure_enabled()
|
|
self.ensure_bucket()
|
|
try:
|
|
self.client.remove_object(self.bucket, object_key)
|
|
except S3Error as exc:
|
|
raise ObjectStorageError(str(exc)) from exc
|
|
|
|
def put_bytes(self, object_key: str, content: bytes, content_type: str = "application/octet-stream") -> dict[str, Any]:
|
|
self._ensure_enabled()
|
|
self.ensure_bucket()
|
|
try:
|
|
result = self.client.put_object(self.bucket, object_key, BytesIO(content), len(content), content_type=content_type)
|
|
return {"bucket": self.bucket, "object_key": object_key, "etag": result.etag, "byte_size": len(content)}
|
|
except S3Error as exc:
|
|
raise ObjectStorageError(str(exc)) from exc
|
|
|
|
|
|
@lru_cache
|
|
def get_object_storage() -> MinioObjectStorage:
|
|
return MinioObjectStorage()
|