TLUSTY/cno_grid/src/qiniu_store.py
2026-07-23 19:06:45 +08:00

264 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""七牛云对象存储的轻量封装(零 SDK 依赖)。
仅用 Python 标准库hmac/hashlib/base64/json+ curl 实现七牛云的上传/
下载/列举。worker 端无需 pip 装任何东西。
七牛上传签名机制:
PutPolicy = {"scope":"<bucket>:<key>", "deadline":<unix秒>}
编码后用 AccessKey/SecretKey 做 HMAC-SHA1 签名,得到 uploadToken。
用途(本分布式网格):
- 七牛云当"种子库":每个收敛模型的 .7 大气393KB上传为
<seed_prefix>/<model_name>.7,供异地 worker 在 seed_step 回退时拉取。
- 仅 master 持有 AK/SK 生成 uptokenworker 只拿 token 上传(无需密钥)。
- 大产物(.spec 等 13MB不上传留各机本地。
环境变量:
QINIU_ACCESS_KEY / QINIU_SECRET_KEY / QINIU_BUCKET / QINIU_DOMAIN
(可被 dist_config.yaml 的 qiniu 段覆盖master 在派发时把 token 传给 worker
"""
import base64
import hashlib
import hmac
import json
import os
import subprocess
import time
try:
import urllib.request as urlreq
except ImportError: # py2 兜底(不会触发)
urlreq = None
class QiniuStore:
"""七牛云存储客户端。master 与 worker 共用worker 只用 download/upload(token)。"""
def __init__(self, access_key=None, secret_key=None, bucket=None,
domain=None, seed_prefix="seeds"):
self.ak = access_key or os.environ.get("QINIU_ACCESS_KEY", "")
self.sk = secret_key or os.environ.get("QINIU_SECRET_KEY", "")
self.bucket = bucket or os.environ.get("QINIU_BUCKET", "")
# 绑定域名(如 http:// 或 https:// + 域名,无尾斜杠)。公开读下载用。
self.domain = (domain or os.environ.get("QINIU_DOMAIN", "")).rstrip("/")
self.seed_prefix = seed_prefix
# ---- base64 url-safe七牛要求----
@staticmethod
def _b64u(data):
"""bytes -> url-safe base64 字符串(去 padding 也可,七牛两边都接受)。"""
if isinstance(data, str):
data = data.encode("utf-8")
return base64.urlsafe_b64encode(data).decode("ascii")
@staticmethod
def _b64u_json(obj):
return QiniuStore._b64u(json.dumps(obj, separators=(",", ":")))
def _sign(self, data):
"""HMAC-SHA1(data, sk) -> url-safe base64。"""
if isinstance(data, str):
data = data.encode("utf-8")
return self._b64u(hmac.new(self.sk.encode("utf-8"), data,
hashlib.sha1).digest())
# ---- uptoken 生成master 持有 AK/SK 时调用)----
def gen_uptoken(self, key, expires=3600):
"""为指定 key 生成上传 token。
key: 对象名(不含 bucket 前缀),如 "seeds/t30000_g5.0_he0_c-1_n-1_o-1.7"
返回 uploadToken 字符串,交给 worker 用 curl 上传。
"""
scope = "{}:{}".format(self.bucket, key) if key else self.bucket
policy = {"scope": scope, "deadline": int(time.time()) + expires}
encoded = self._b64u_json(policy)
sign = self._sign(encoded)
return "{}:{}".format(self.ak, sign) + ":" + encoded
def gen_private_url(self, key, expires=3600):
"""生成私有空间的临时下载 URL带 e/token 签名)。
若 bucket 为公开读,直接用 domain/key 即可,不必调这个。
"""
if not self.domain:
raise ValueError("QINIU_DOMAIN 未配置")
url = "{}/{}".format(self.domain, key)
e = int(time.time()) + expires
to_sign = "{}?e={}".format(url, e)
token = "{}:{}".format(self.ak, self._sign(to_sign))
return "{}?e={}&token={}".format(url, e, token)
def public_url(self, key):
"""公开空间的下载 URL无签名"""
if not self.domain:
raise ValueError("QINIU_DOMAIN 未配置")
return "{}/{}".format(self.domain, key)
# ---- 上传 / 下载(通过 curlworker 无需 pip----
def upload(self, local_path, key, uptoken=None):
"""上传本地文件到七牛。uptoken 可由 master 预生成传入。
用 curl 表单 POST七牛上传接口
POST http://upload.qiniup.com multipart/form-data
field "token"=uptoken, "key"=key, "file"=@local_path
返回 (ok:bool, resp_text)。
"""
if uptoken is None:
uptoken = self.gen_uptoken(key)
# 七牛上传域名:华东 upload.qiniup.com其它区域用对应域名。
upload_host = os.environ.get("QINIU_UPLOAD_HOST", "upload.qiniup.com")
url = "http://{}/".format(upload_host)
cmd = ["curl", "-sS", "-m", "300", "-X", "POST", url,
"-F", "token={}".format(uptoken),
"-F", "key={}".format(key),
"-F", "file=@{}".format(local_path)]
try:
out = subprocess.run(cmd, capture_output=True, text=True,
timeout=320)
except subprocess.TimeoutExpired:
return False, "upload timeout (>300s)"
if out.returncode != 0:
return False, out.stderr.strip() or out.stdout.strip()
try:
resp = json.loads(out.stdout)
except ValueError:
return False, out.stdout.strip()
# 成功响应含 "key" 与 "hash";失败含 "error"
if "error" in resp:
return False, resp["error"]
return True, resp.get("key", "")
def download(self, key, local_path, timeout=120):
"""下载对象到本地。公开空间用 public_url私有空间用 private_url。
返回 True/False。
"""
if self.ak and self.sk:
url = self.gen_private_url(key)
else:
url = self.public_url(key)
cmd = ["curl", "-sS", "-m", str(timeout), "-f", "-o", local_path, url]
try:
r = subprocess.run(cmd, capture_output=True, timeout=timeout + 10)
return r.returncode == 0
except subprocess.TimeoutExpired:
return False
# ---- 种子库语义封装 ----
def seed_key(self, model_name):
"""规范种子对象名seeds/<model>.7"""
return "{}/{}.7".format(self.seed_prefix, model_name)
def upload_seed(self, local_path, model_name, uptoken=None):
"""上传某模型的最终大气 .7 作为种子。返回 (ok, msg)。"""
return self.upload(local_path, self.seed_key(model_name), uptoken)
def download_seed(self, model_name, local_path):
"""下载某模型的种子 .7 到 local_path。返回 True/False。"""
return self.download(self.seed_key(model_name), local_path)
# ---- 列举(用于 find_seed 跨网查最近邻种子)----
def list_seeds(self):
"""返回七牛种子库中所有 <model_name>.7 的模型名列表。
调用七牛 RS API: GET /list?bucket=<b>&prefix=<seed_prefix>
(需 AK/SK 签名)。返回 [] 当未配置或失败。
结果缓存到本地文件避免每次都拉(见 _seeds_cache_path
"""
if not (self.ak and self.sk and self.bucket):
return []
cache = self._seeds_cache_path()
# 缓存 5 分钟
if os.path.isfile(cache) and time.time() - os.path.getmtime(cache) < 300:
try:
return json.load(open(cache))
except Exception:
pass
names = self._fetch_seed_list()
try:
with open(cache, "w") as f:
json.dump(names, f)
except Exception:
pass
return names
def _seeds_cache_path(self):
return os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", ".qiniu_seeds_cache.json")
def _fetch_seed_list(self):
"""分页拉取七牛种子列表,返回 model_name 列表。"""
rs_host = os.environ.get("QINIU_RS_HOST", "http://rsf.qiniuapi.com")
names = []
marker = ""
prefix = self.seed_prefix + "/"
while True:
path = "/list?bucket={}&prefix={}".format(self.bucket, prefix)
if marker:
path += "&marker={}".format(marker)
# 签名AccessToken = AK:urlsafe_b64(hmac(sk, path)):<path>
encoded_path = path # 七牛签名用原始 path
sign = self._sign(encoded_path)
access_token = "{}:{}".format(self.ak, sign) + encoded_path
url = rs_host + path
cmd = ["curl", "-sS", "-m", "60",
"-H", "Authorization: QBox " + access_token, url]
try:
r = subprocess.run(cmd, capture_output=True, text=True,
timeout=70)
if r.returncode != 0:
break
data = json.loads(r.stdout)
except (ValueError, subprocess.TimeoutExpired):
break
for item in data.get("items", []):
k = item.get("key", "")
# seeds/<model>.7 -> <model>
if k.startswith(prefix) and k.endswith(".7"):
names.append(k[len(prefix):-2])
marker = data.get("marker", "")
if not marker:
break
return names
def load_from_dist_config(cfg):
"""从 dist_config.yaml 的 qiniu 段构造 QiniuStore。"""
q = (cfg or {}).get("qiniu", {}) if isinstance(cfg, dict) else {}
return QiniuStore(
access_key=q.get("access_key"),
secret_key=q.get("secret_key"),
bucket=q.get("bucket"),
domain=q.get("domain"),
seed_prefix=q.get("seed_prefix", "seeds"),
)
if __name__ == "__main__":
# 自检:打印 token需配置 AK/SK/Bucket
import argparse
ap = argparse.ArgumentParser(description="qiniu store 自检")
ap.add_argument("--gen-token", help="为某 key 生成 uptoken")
ap.add_argument("--list", action="store_true", help="列出种子")
ap.add_argument("--config", help="dist_config.yaml 路径")
args = ap.parse_args()
if args.config:
try:
import yaml
cfg = yaml.safe_load(open(args.config))
except Exception:
# 兜底:不装 yaml 时只从环境变量读
cfg = {"qiniu": {}}
store = load_from_dist_config(cfg)
else:
store = QiniuStore()
if args.gen_token:
print(store.gen_uptoken(args.gen_token))
elif args.list:
for n in store.list_seeds():
print(n)
else:
print("AK set:", bool(store.ak), "Bucket:", store.bucket,
"Domain:", store.domain or "(未配置)")