525 lines
20 KiB
Python
525 lines
20 KiB
Python
#!/usr/bin/env python3
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import io
|
||
import json
|
||
import os
|
||
import sys
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
import urllib3
|
||
from minio import Minio
|
||
from minio.error import S3Error
|
||
|
||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
if PROJECT_ROOT not in sys.path:
|
||
sys.path.insert(0, PROJECT_ROOT)
|
||
|
||
from config import (
|
||
MINIO_ENABLED,
|
||
MINIO_ENDPOINT,
|
||
MINIO_ACCESS_KEY,
|
||
MINIO_SECRET_KEY,
|
||
MINIO_BUCKET,
|
||
MINIO_SECURE,
|
||
APK_MINIO_MAX_BYTES,
|
||
)
|
||
|
||
DOWNLOAD_QUEUE_KEY = "download-queue/current_batch.json"
|
||
RESULTS_PREFIX = "download-results/"
|
||
APKS_PREFIX = "apks/"
|
||
|
||
|
||
class MinioStorage:
|
||
"""
|
||
Minio 对象存储客户端
|
||
|
||
注意:此类依赖 Minio 服务,仅在 MINIO_ENABLED=True 时可用。
|
||
跨团队部署时,如果没有 Minio 服务,请在配置中设置 MINIO_ENABLED=False。
|
||
|
||
降级方案:
|
||
- 当 MINIO_ENABLED=False 时,直接实例化会抛出 RuntimeError
|
||
- 推荐使用 create() 工厂方法,它会在禁用时返回 None
|
||
- 调用方应检查返回值并使用直接下载模式(APK_DOWNLOAD_MODE='direct')
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
endpoint: str = MINIO_ENDPOINT,
|
||
access_key: str = MINIO_ACCESS_KEY,
|
||
secret_key: str = MINIO_SECRET_KEY,
|
||
bucket: str = MINIO_BUCKET,
|
||
secure: bool = MINIO_SECURE,
|
||
):
|
||
if not MINIO_ENABLED:
|
||
raise RuntimeError(
|
||
"Minio 功能已禁用(MINIO_ENABLED=False)。\n"
|
||
"如需使用 Minio,请在配置文件中设置 MINIO_ENABLED=True 并配置 Minio 连接信息。\n"
|
||
"跨团队部署时,推荐使用 create() 工厂方法和 APK_DOWNLOAD_MODE='direct' 直接下载模式。"
|
||
)
|
||
|
||
if not endpoint or not access_key or not secret_key:
|
||
raise ValueError(
|
||
"Minio 配置不完整。请在配置文件中设置:\n"
|
||
" - MINIO_ENDPOINT\n"
|
||
" - MINIO_ACCESS_KEY\n"
|
||
" - MINIO_SECRET_KEY"
|
||
)
|
||
|
||
self.endpoint = endpoint
|
||
self.bucket = bucket
|
||
http_client = urllib3.PoolManager(
|
||
timeout=urllib3.Timeout(connect=10, read=60),
|
||
maxsize=30,
|
||
retries=urllib3.Retry(total=3, backoff_factor=0.3),
|
||
)
|
||
self.client = Minio(endpoint, access_key=access_key, secret_key=secret_key,
|
||
secure=secure, http_client=http_client)
|
||
self._ensure_bucket()
|
||
|
||
@classmethod
|
||
def create(
|
||
cls,
|
||
endpoint: str = MINIO_ENDPOINT,
|
||
access_key: str = MINIO_ACCESS_KEY,
|
||
secret_key: str = MINIO_SECRET_KEY,
|
||
bucket: str = MINIO_BUCKET,
|
||
secure: bool = MINIO_SECURE,
|
||
) -> Optional["MinioStorage"]:
|
||
"""
|
||
工厂方法:创建 MinioStorage 实例,如果 MINIO_ENABLED=False 则返回 None。
|
||
|
||
用法示例:
|
||
storage = MinioStorage.create()
|
||
if storage is None:
|
||
# 降级到直接下载模式
|
||
print("Minio 已禁用,使用直接下载模式")
|
||
return
|
||
|
||
返回:
|
||
MinioStorage 实例或 None(当 MINIO_ENABLED=False 时)
|
||
"""
|
||
if not MINIO_ENABLED:
|
||
return None
|
||
return cls(endpoint, access_key, secret_key, bucket, secure)
|
||
|
||
@staticmethod
|
||
def is_enabled() -> bool:
|
||
"""
|
||
检查 Minio 功能是否启用。
|
||
|
||
返回:
|
||
bool: True 表示 Minio 已启用并可用
|
||
"""
|
||
return MINIO_ENABLED
|
||
|
||
def _ensure_bucket(self) -> None:
|
||
if not self.client.bucket_exists(self.bucket):
|
||
self.client.make_bucket(self.bucket)
|
||
|
||
# ── low-level ops ──────────────────────────────────────────────
|
||
|
||
def upload_file(self, local_path: str, object_name: str) -> str:
|
||
with open(local_path, "rb") as fh:
|
||
fh.seek(0, os.SEEK_END)
|
||
size = fh.tell()
|
||
fh.seek(0)
|
||
result = self.client.put_object(
|
||
self.bucket, object_name, fh, length=size,
|
||
)
|
||
return result.etag or ""
|
||
|
||
def upload_json(self, data: Any, object_name: str) -> str:
|
||
payload = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
|
||
self.client.put_object(self.bucket, object_name, io.BytesIO(payload),
|
||
length=len(payload), content_type="application/json")
|
||
return object_name
|
||
|
||
def download_file(self, object_name: str, local_path: str,
|
||
*, overwrite: bool = True) -> str:
|
||
os.makedirs(os.path.dirname(local_path) or ".", exist_ok=True)
|
||
self.client.fget_object(self.bucket, object_name, local_path)
|
||
return local_path
|
||
|
||
def download_json(self, object_name: str, retries: int = 3) -> Optional[Any]:
|
||
import time as _time
|
||
for attempt in range(retries):
|
||
try:
|
||
response = self.client.get_object(self.bucket, object_name)
|
||
data = response.read()
|
||
response.close()
|
||
response.release_conn()
|
||
return json.loads(data.decode("utf-8"))
|
||
except S3Error as exc:
|
||
if exc.code == "NoSuchKey":
|
||
return None
|
||
if attempt < retries - 1:
|
||
_time.sleep(1 * (attempt + 1))
|
||
else:
|
||
raise
|
||
except Exception as exc:
|
||
if attempt < retries - 1:
|
||
_time.sleep(1 * (attempt + 1))
|
||
else:
|
||
raise
|
||
return None
|
||
|
||
def list_objects(self, prefix: str = "") -> List[Dict[str, Any]]:
|
||
objects = self.client.list_objects(self.bucket, prefix=prefix, recursive=True)
|
||
return [
|
||
{"name": obj.object_name, "size": obj.size or 0,
|
||
"last_modified": obj.last_modified.isoformat() if obj.last_modified else ""}
|
||
for obj in objects
|
||
]
|
||
|
||
def delete_object(self, object_name: str) -> None:
|
||
self.client.remove_object(self.bucket, object_name)
|
||
|
||
def object_exists(self, object_name: str) -> bool:
|
||
try:
|
||
self.client.stat_object(self.bucket, object_name)
|
||
return True
|
||
except S3Error as exc:
|
||
if exc.code == "NoSuchKey":
|
||
return False
|
||
raise
|
||
|
||
# ── download queue ─────────────────────────────────────────────
|
||
|
||
def push_download_queue(self, tasks: List[Dict[str, str]]) -> None:
|
||
tasks = [t for t in tasks if self._is_valid_queue_entry(t)]
|
||
self.upload_json(tasks, DOWNLOAD_QUEUE_KEY)
|
||
|
||
def prepend_to_download_queue(self, tasks: List[Dict[str, str]]) -> None:
|
||
existing = self.pull_download_queue()
|
||
existing = [t for t in existing if self._is_valid_queue_entry(t)]
|
||
existing_packages = {t.get("package_name", "") for t in existing}
|
||
for task in reversed(tasks):
|
||
pkg = task.get("package_name", "")
|
||
if pkg in existing_packages:
|
||
existing = [t for t in existing if t.get("package_name") != pkg]
|
||
existing.insert(0, task)
|
||
self.upload_json(existing, DOWNLOAD_QUEUE_KEY)
|
||
|
||
def pull_download_queue(self) -> List[Dict[str, str]]:
|
||
import time as _time
|
||
for attempt in range(3):
|
||
try:
|
||
data = self.download_json(DOWNLOAD_QUEUE_KEY)
|
||
if not isinstance(data, list):
|
||
return []
|
||
return [t for t in data if self._is_valid_queue_entry(t)]
|
||
except Exception:
|
||
if attempt == 2:
|
||
return []
|
||
_time.sleep(1 * (attempt + 1))
|
||
|
||
@staticmethod
|
||
def _is_valid_queue_entry(task: Dict[str, str]) -> bool:
|
||
pkg = str(task.get("package_name") or "").strip()
|
||
if not pkg:
|
||
return False
|
||
import re
|
||
if not re.match(r'^[a-zA-Z][a-zA-Z0-9_.]{2,}$', pkg):
|
||
return False
|
||
return True
|
||
|
||
# ── APK upload / download ──────────────────────────────────────
|
||
|
||
def upload_apk(self, package_name: str, local_dir: str,
|
||
download_date: str, version_code: str = "",
|
||
delete_after_upload: bool = False) -> Dict[str, Any]:
|
||
manifest: Dict[str, Any] = {
|
||
"package_name": package_name,
|
||
"download_date": download_date,
|
||
"version_code": version_code,
|
||
"files": [],
|
||
}
|
||
pkg_dir = os.path.join(local_dir, package_name)
|
||
if not os.path.isdir(pkg_dir):
|
||
raise FileNotFoundError(f"APK directory not found: {pkg_dir}")
|
||
|
||
for filename in sorted(os.listdir(pkg_dir)):
|
||
filepath = os.path.join(pkg_dir, filename)
|
||
if not os.path.isfile(filepath):
|
||
continue
|
||
if not filename.endswith((".apk", ".xapk", ".apkm")):
|
||
continue
|
||
|
||
md5 = _file_md5(filepath)
|
||
filesize = os.path.getsize(filepath)
|
||
remote_name = f"{APKS_PREFIX}{package_name}/{download_date}_{version_code}/{filename}"
|
||
self.upload_file(filepath, remote_name)
|
||
|
||
manifest["files"].append({
|
||
"remote_name": remote_name,
|
||
"filename": filename,
|
||
"size": filesize,
|
||
"md5": md5,
|
||
})
|
||
|
||
return manifest
|
||
|
||
def download_apk(self, package_name: str, manifest: Dict[str, Any],
|
||
dest_dir: str) -> List[str]:
|
||
local_paths: List[str] = []
|
||
pkg_dir = os.path.join(dest_dir, package_name)
|
||
|
||
for old_file in os.listdir(pkg_dir) if os.path.isdir(pkg_dir) else []:
|
||
if old_file.endswith(('.apk', '.xapk', '.apkm')):
|
||
try:
|
||
os.remove(os.path.join(pkg_dir, old_file))
|
||
except OSError:
|
||
pass
|
||
os.makedirs(pkg_dir, exist_ok=True)
|
||
|
||
# 优先使用 uploaded_files(包含 remote_name),回退到 files
|
||
file_list = manifest.get("uploaded_files") or manifest.get("files") or []
|
||
for file_info in file_list:
|
||
remote_name = file_info["remote_name"]
|
||
filename = file_info.get("filename", os.path.basename(remote_name))
|
||
local_path = os.path.join(pkg_dir, filename)
|
||
|
||
self.download_file(remote_name, local_path)
|
||
|
||
actual_md5 = _file_md5(local_path)
|
||
expected_md5 = file_info.get("md5", "")
|
||
if expected_md5 and actual_md5 != expected_md5:
|
||
os.unlink(local_path)
|
||
raise IOError(
|
||
f"MD5 mismatch for {filename}: expected {expected_md5}, got {actual_md5}"
|
||
)
|
||
|
||
local_paths.append(local_path)
|
||
|
||
return local_paths
|
||
|
||
# ── results ────────────────────────────────────────────────────
|
||
|
||
def write_download_result(self, package_name: str,
|
||
result: Dict[str, Any]) -> None:
|
||
key = f"{RESULTS_PREFIX}{package_name}.json"
|
||
self.upload_json(result, key)
|
||
|
||
def read_download_result(self, package_name: str) -> Optional[Dict[str, Any]]:
|
||
key = f"{RESULTS_PREFIX}{package_name}.json"
|
||
return self.download_json(key)
|
||
|
||
def list_download_results(self) -> Dict[str, Dict[str, Any]]:
|
||
results: Dict[str, Dict[str, Any]] = {}
|
||
for obj in self.list_objects(RESULTS_PREFIX):
|
||
if not obj["name"].endswith(".json"):
|
||
continue
|
||
package_name = obj["name"][len(RESULTS_PREFIX):-len(".json")]
|
||
if not package_name:
|
||
continue
|
||
data = self.download_json(obj["name"])
|
||
if isinstance(data, dict):
|
||
results[package_name] = data
|
||
return results
|
||
|
||
def delete_download_result(self, package_name: str) -> None:
|
||
key = f"{RESULTS_PREFIX}{package_name}.json"
|
||
self.delete_object(key)
|
||
|
||
# ── old APK cleanup ────────────────────────────────────────────
|
||
|
||
def list_apk_versions(self, package_name: str) -> List[str]:
|
||
prefix = f"{APKS_PREFIX}{package_name}/"
|
||
objects = self.list_objects(prefix)
|
||
dirs = set()
|
||
for obj in objects:
|
||
rel = obj["name"][len(prefix):]
|
||
parts = rel.split("/")
|
||
if len(parts) >= 2:
|
||
dirs.add(parts[0])
|
||
return sorted(dirs)
|
||
|
||
def delete_apk_version(self, package_name: str, version_dir: str) -> None:
|
||
prefix = f"{APKS_PREFIX}{package_name}/{version_dir}/"
|
||
for obj in self.list_objects(prefix):
|
||
self.delete_object(obj["name"])
|
||
|
||
def cleanup_old_versions(self, package_name: str, keep: int = 1) -> int:
|
||
versions = self.list_apk_versions(package_name)
|
||
if len(versions) <= keep:
|
||
return 0
|
||
deleted = 0
|
||
for version in versions[:-keep]:
|
||
self.delete_apk_version(package_name, version)
|
||
deleted += 1
|
||
return deleted
|
||
|
||
# ── storage limit enforcement ───────────────────────────────────
|
||
|
||
def list_all_versions_by_age(self) -> List[Dict[str, Any]]:
|
||
"""按版本聚合列出所有 APK 版本,按最早修改时间排序。
|
||
|
||
每个版本条目包含该版本下所有文件的总大小和最晚 last_modified。
|
||
这确保了 enforce_storage_limit 能正确统计每个版本的完整大小。
|
||
"""
|
||
# 先按文件遍历,按 (package_name, version_dir) 聚合
|
||
version_map: Dict[str, Dict[str, Any]] = {}
|
||
for obj in self.list_objects(APKS_PREFIX):
|
||
name = obj["name"]
|
||
if not name.endswith((".apk", ".xapk", ".apkm")):
|
||
continue
|
||
rel = name[len(APKS_PREFIX):]
|
||
parts = rel.split("/", 2)
|
||
if len(parts) < 3:
|
||
continue
|
||
package_name = parts[0]
|
||
version_dir = parts[1]
|
||
key = f"{package_name}/{version_dir}"
|
||
file_size = obj.get("size", 0)
|
||
file_modified = obj.get("last_modified", "")
|
||
|
||
if key not in version_map:
|
||
version_map[key] = {
|
||
"package_name": package_name,
|
||
"version_dir": version_dir,
|
||
"size": file_size,
|
||
"last_modified": file_modified,
|
||
}
|
||
else:
|
||
# 累加文件大小,取最晚的 last_modified
|
||
version_map[key]["size"] += file_size
|
||
if file_modified > version_map[key]["last_modified"]:
|
||
version_map[key]["last_modified"] = file_modified
|
||
|
||
versions = list(version_map.values())
|
||
versions.sort(key=lambda v: v.get("last_modified", ""))
|
||
return versions
|
||
|
||
def enforce_storage_limit(self, max_bytes: int = 0) -> dict:
|
||
limit = max_bytes or APK_MINIO_MAX_BYTES
|
||
# list_all_versions_by_age 已按版本聚合,size 为该版本所有文件的总大小
|
||
all_versions = self.list_all_versions_by_age()
|
||
current_size = sum(v["size"] for v in all_versions)
|
||
if current_size <= limit:
|
||
return {"before_bytes": current_size, "after_bytes": current_size,
|
||
"deleted_versions": 0, "deleted_bytes": 0}
|
||
if not all_versions:
|
||
return {"before_bytes": current_size, "after_bytes": current_size,
|
||
"deleted_versions": 0, "deleted_bytes": 0}
|
||
|
||
# 清理到 limit 的 80%
|
||
target = int(limit * 0.8)
|
||
deleted_bytes = 0
|
||
to_delete: List[Dict[str, Any]] = []
|
||
|
||
for ver in all_versions:
|
||
if current_size - deleted_bytes <= target:
|
||
break
|
||
to_delete.append(ver)
|
||
# ver["size"] 已是该版本所有文件的总大小,无需额外聚合
|
||
deleted_bytes += ver["size"]
|
||
|
||
for ver in to_delete:
|
||
self.delete_apk_version(ver["package_name"], ver["version_dir"])
|
||
|
||
after_versions = self.list_all_versions_by_age()
|
||
after_size = sum(v["size"] for v in after_versions)
|
||
return {
|
||
"before_bytes": current_size,
|
||
"after_bytes": after_size,
|
||
"deleted_versions": len(to_delete),
|
||
"deleted_bytes": deleted_bytes,
|
||
}
|
||
|
||
|
||
# ── helpers ────────────────────────────────────────────────────────
|
||
|
||
def _file_md5(filepath: str) -> str:
|
||
h = hashlib.md5()
|
||
with open(filepath, "rb") as fh:
|
||
for chunk in iter(lambda: fh.read(65536), b""):
|
||
h.update(chunk)
|
||
return h.hexdigest()
|
||
|
||
|
||
# ── test ───────────────────────────────────────────────────────────
|
||
|
||
def main() -> int:
|
||
print(f"MinIO enabled: {MINIO_ENABLED}")
|
||
if not MINIO_ENABLED:
|
||
print("MinIO is disabled. Set MINIO_ENABLED=True to run this test.")
|
||
return 0
|
||
|
||
print(f"MinIO endpoint: {MINIO_ENDPOINT}")
|
||
print(f"MinIO bucket: {MINIO_BUCKET}")
|
||
print(f"MinIO secure: {MINIO_SECURE}")
|
||
|
||
# 使用工厂方法创建实例
|
||
storage = MinioStorage.create()
|
||
if storage is None:
|
||
print("Failed to create MinioStorage instance (MINIO_ENABLED=False)")
|
||
return 1
|
||
|
||
# 1. Test push/pull download queue
|
||
test_tasks = [
|
||
{"package_name": "com.test.app1", "last_updated": "2026-05-01", "app_name": "Test App 1"},
|
||
{"package_name": "com.test.app2", "last_updated": "2026-05-15", "app_name": "Test App 2"},
|
||
]
|
||
print("\n[1] push_download_queue ...")
|
||
storage.push_download_queue(test_tasks)
|
||
print(" pushed OK")
|
||
|
||
pulled = storage.pull_download_queue()
|
||
print(f" pulled: {len(pulled)} tasks")
|
||
for t in pulled:
|
||
print(f" {t['package_name']}")
|
||
|
||
# 2. Test upload/download a dummy file
|
||
print("\n[2] upload / download file ...")
|
||
dummy_dir = "/tmp/apk_cloud_test/com.test.app1"
|
||
os.makedirs(dummy_dir, exist_ok=True)
|
||
dummy_path = os.path.join(dummy_dir, "base.apk")
|
||
with open(dummy_path, "w") as fh:
|
||
fh.write("fake apk content for testing\n")
|
||
manifest = storage.upload_apk(
|
||
"com.test.app1", "/tmp/apk_cloud_test", download_date="2026-05-19", version_code="1",
|
||
)
|
||
print(f" uploaded manifest: {json.dumps(manifest, indent=2)}")
|
||
|
||
# 3. Test write / read download result
|
||
result = {
|
||
"status": "ok",
|
||
"download_date": "2026-05-19T10:00:00",
|
||
"version_code": "1",
|
||
"files": manifest["files"],
|
||
}
|
||
storage.write_download_result("com.test.app1", result)
|
||
read_back = storage.read_download_result("com.test.app1")
|
||
print(f"\n[3] write/read result: status={read_back.get('status') if read_back else 'NONE'}")
|
||
|
||
# 4. Test download APK
|
||
print("\n[4] download APK ...")
|
||
os.makedirs("/tmp/apk_cloud_test_dl", exist_ok=True)
|
||
local_paths = storage.download_apk("com.test.app1", manifest, "/tmp/apk_cloud_test_dl")
|
||
print(f" downloaded: {local_paths}")
|
||
|
||
# 5. List results
|
||
print("\n[5] list_download_results ...")
|
||
all_results = storage.list_download_results()
|
||
for pkg, res in all_results.items():
|
||
print(f" {pkg}: {res.get('status')}")
|
||
|
||
# Cleanup test data
|
||
print("\n[6] cleanup ...")
|
||
storage.delete_download_result("com.test.app1")
|
||
storage.delete_object(DOWNLOAD_QUEUE_KEY)
|
||
storage.delete_apk_version("com.test.app1", "2026-05-19_1")
|
||
import shutil
|
||
shutil.rmtree("/tmp/apk_cloud_test", ignore_errors=True)
|
||
shutil.rmtree("/tmp/apk_cloud_test_dl", ignore_errors=True)
|
||
print(" done")
|
||
|
||
print("\nAll tests passed!")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|