instance-repo 1.0.9.dev6__tar.gz → 1.1.0.dev0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/PKG-INFO +1 -1
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/__init__.py +1 -1
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/_site_defaults.py +12 -0
- instance_repo-1.1.0.dev0/instance_repo/artifacts.py +347 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/cli.py +75 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/clients/instances.py +123 -21
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/clients/workflows.py +0 -1
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/repo.py +82 -1
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/store/oss.py +111 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/PKG-INFO +1 -1
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/SOURCES.txt +1 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/pyproject.toml +1 -1
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/README.md +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/_routing.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/bootstrap.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/cache.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/clients/__init__.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/clients/benchmarks.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/clients/config.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/clients/datasets.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/clients/images.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/clients/reports.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/clients/scaffold.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/clients/splits.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/clients/versions.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/concurrency.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/content.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/endpoints.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/errors.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/image.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/layout.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/loader.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/models.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/paths.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/release.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/retry.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/store/__init__.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/store/acr.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/store/base.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/store/registry.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/tasktoml.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/transport.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo/validate.py +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/dependency_links.txt +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/entry_points.txt +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/requires.txt +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/top_level.txt +0 -0
- {instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/setup.cfg +0 -0
|
@@ -25,3 +25,15 @@ from __future__ import annotations
|
|
|
25
25
|
#: **var**,内网渠道经 ``-ldflags -X`` 注入)与 Java 侧 ``SiteDefaults.API_BASE``
|
|
26
26
|
#: (三语言须一致,且各自保留构建期注入方式)。
|
|
27
27
|
API_BASE = ""
|
|
28
|
+
|
|
29
|
+
#: AP job 查询端点(artifact 下载用)的站点默认值。源码里恒为空串;内网构建时注入。
|
|
30
|
+
AP_ENDPOINT = ""
|
|
31
|
+
|
|
32
|
+
#: artifact 对象的 OSS 前缀根(如 {root}/{job_id}/result.tgz)。源码恒为空;
|
|
33
|
+
#: 内网构建时注入。
|
|
34
|
+
ARTIFACT_PREFIX_ROOT = ""
|
|
35
|
+
|
|
36
|
+
#: 「AP cluster 名 → artifact OSS bucket」映射的 JSON 串。源码恒为空;内网构建时
|
|
37
|
+
#: 注入。运行时也可经环境变量 AP_ARTIFACT_BUCKET_MAP(JSON)覆盖,优先级:
|
|
38
|
+
#: 环境变量 > 本模块注入值 > 空(空则 artifact 下载报错并引导配置)。
|
|
39
|
+
ARTIFACT_BUCKET_MAP_JSON = ""
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""artifacts.py — AP job artifact 下载(STS 直连 OSS,绕开 dataplane)。
|
|
2
|
+
|
|
3
|
+
调用方:普通用户 / UTS SDK,透传 apikey + job_id + cluster_id。
|
|
4
|
+
鉴权:AP workspace ACL(SDK 调 AP GET /api/jobs/{id} 时自动检查)。
|
|
5
|
+
STS:apiserver POST /apis/v1/credentials,resource=oss,artifact 前缀强制只读。
|
|
6
|
+
OSS:{ARTIFACT_PREFIX_ROOT}/{job_id}/result.tgz(前缀根经 _site_defaults 注入)。
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import posixpath
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from .errors import Forbidden, InstanceRepoError
|
|
20
|
+
from . import _site_defaults
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# 常量
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# 常量(业务值一律经 _site_defaults 注入,源码里不硬编码,见 _site_defaults.py)
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
def _artifact_bucket_map() -> dict[str, str]:
|
|
33
|
+
"""解析 artifact bucket 映射:环境变量 > 站点默认 > 空。
|
|
34
|
+
|
|
35
|
+
空则抛错并引导配置——业务桶名不进公网包源码(脱敏红线)。
|
|
36
|
+
"""
|
|
37
|
+
raw = (os.environ.get("AP_ARTIFACT_BUCKET_MAP", "")
|
|
38
|
+
or _site_defaults.ARTIFACT_BUCKET_MAP_JSON).strip()
|
|
39
|
+
if not raw:
|
|
40
|
+
raise InstanceRepoError(
|
|
41
|
+
"artifact bucket map is not configured; set AP_ARTIFACT_BUCKET_MAP "
|
|
42
|
+
"(JSON {\"<cluster>\": \"<bucket>\", ...}) or use an internal build "
|
|
43
|
+
"with the map injected")
|
|
44
|
+
try:
|
|
45
|
+
data = json.loads(raw)
|
|
46
|
+
except ValueError as exc:
|
|
47
|
+
raise InstanceRepoError(
|
|
48
|
+
f"AP_ARTIFACT_BUCKET_MAP must be valid JSON: {exc}") from exc
|
|
49
|
+
if not isinstance(data, dict) or not all(
|
|
50
|
+
isinstance(k, str) and isinstance(v, str) for k, v in data.items()):
|
|
51
|
+
raise InstanceRepoError(
|
|
52
|
+
"AP_ARTIFACT_BUCKET_MAP must be a JSON object of string pairs")
|
|
53
|
+
return data
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _artifact_prefix_root() -> str:
|
|
57
|
+
root = (os.environ.get("AP_ARTIFACT_PREFIX_ROOT", "")
|
|
58
|
+
or _site_defaults.ARTIFACT_PREFIX_ROOT).strip()
|
|
59
|
+
if not root:
|
|
60
|
+
raise InstanceRepoError(
|
|
61
|
+
"artifact prefix root is not configured; set AP_ARTIFACT_PREFIX_ROOT "
|
|
62
|
+
"or use an internal build with the value injected")
|
|
63
|
+
return root
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _ap_endpoint(ap_endpoint: str | None) -> str:
|
|
67
|
+
endpoint = (ap_endpoint
|
|
68
|
+
or os.environ.get("INSTANCE_REPO_AP_ENDPOINT")
|
|
69
|
+
or _site_defaults.AP_ENDPOINT).strip()
|
|
70
|
+
if not endpoint:
|
|
71
|
+
raise InstanceRepoError(
|
|
72
|
+
"AP endpoint is not configured; set INSTANCE_REPO_AP_ENDPOINT "
|
|
73
|
+
"or use an internal build with the default injected")
|
|
74
|
+
return endpoint.rstrip("/")
|
|
75
|
+
|
|
76
|
+
# 批量下载默认并发数
|
|
77
|
+
DEFAULT_MAX_CONCURRENT = 8
|
|
78
|
+
|
|
79
|
+
# OSS 限流退避参数
|
|
80
|
+
OSS_RETRY_INITIAL_WAIT = 1.0 # 秒
|
|
81
|
+
OSS_RETRY_MAX_WAIT = 30.0 # 秒
|
|
82
|
+
OSS_RETRY_MAX_ATTEMPTS = 5
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# 异常
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
class ArtifactNotFoundError(InstanceRepoError):
|
|
90
|
+
"""job 不存在或无 artifact。"""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ArtifactPermissionError(Forbidden):
|
|
94
|
+
"""用户无权访问该 job(workspace ACL 拒绝)。"""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# 内部辅助
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def resolve_artifact_bucket(cluster: str) -> str:
|
|
102
|
+
"""根据数据面名查 artifact bucket(映射来自注入配置,非源码硬编码)。"""
|
|
103
|
+
cluster = (cluster or "").strip()
|
|
104
|
+
bucket_map = _artifact_bucket_map()
|
|
105
|
+
bucket = bucket_map.get(cluster)
|
|
106
|
+
if not bucket:
|
|
107
|
+
raise InstanceRepoError(
|
|
108
|
+
f"unknown cluster {cluster!r}; known clusters: "
|
|
109
|
+
f"{sorted(bucket_map)}")
|
|
110
|
+
return bucket
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def artifact_prefix(job_id: str) -> str:
|
|
114
|
+
"""构造 artifact STS 前缀:{root}/{job_id}/"""
|
|
115
|
+
return f"{_artifact_prefix_root()}/{job_id}/"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def artifact_key(job_id: str) -> str:
|
|
119
|
+
"""构造 artifact OSS 对象 key:{root}/{job_id}/result.tgz"""
|
|
120
|
+
return f"{_artifact_prefix_root()}/{job_id}/result.tgz"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def fetch_job_info(api_key: str, job_id: str, cluster: str,
|
|
124
|
+
ap_endpoint: str | None = None) -> dict[str, Any]:
|
|
125
|
+
"""调 AP GET /api/jobs/{job_id}?include_params=true 获取 job 信息。
|
|
126
|
+
|
|
127
|
+
AP 内部执行 workspace ACL 检查(enforce_workspace_action_req)。
|
|
128
|
+
403 → ArtifactPermissionError;404 → ArtifactNotFoundError。
|
|
129
|
+
"""
|
|
130
|
+
endpoint = _ap_endpoint(ap_endpoint)
|
|
131
|
+
url = f"{endpoint}/api/jobs/{job_id}?include_params=true"
|
|
132
|
+
headers = {
|
|
133
|
+
"X-API-Key": api_key,
|
|
134
|
+
"Accept": "application/json",
|
|
135
|
+
}
|
|
136
|
+
if cluster:
|
|
137
|
+
headers["X-Cluster"] = cluster
|
|
138
|
+
|
|
139
|
+
req = urllib.request.Request(url, headers=headers, method="GET")
|
|
140
|
+
try:
|
|
141
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
142
|
+
return json.loads(resp.read().decode())
|
|
143
|
+
except urllib.error.HTTPError as e:
|
|
144
|
+
if e.code == 403:
|
|
145
|
+
raise ArtifactPermissionError(
|
|
146
|
+
f"workspace ACL denied access to job {job_id!r}"
|
|
147
|
+
) from e
|
|
148
|
+
if e.code == 404:
|
|
149
|
+
raise ArtifactNotFoundError(
|
|
150
|
+
f"job {job_id!r} not found"
|
|
151
|
+
) from e
|
|
152
|
+
raise InstanceRepoError(
|
|
153
|
+
f"AP returned HTTP {e.code} for job {job_id!r}: {e.reason}"
|
|
154
|
+
) from e
|
|
155
|
+
except urllib.error.URLError as e:
|
|
156
|
+
raise InstanceRepoError(
|
|
157
|
+
f"failed to reach AP at {endpoint}: {e.reason}"
|
|
158
|
+
) from e
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def fetch_group_job_ids(api_key: str, group_id: str, cluster: str,
|
|
162
|
+
ap_endpoint: str | None = None) -> list[str]:
|
|
163
|
+
"""调 AP GET /groups/{group_id}/jobs 分页收集全部 job_id。"""
|
|
164
|
+
endpoint = (ap_endpoint or os.environ.get("INSTANCE_REPO_AP_ENDPOINT")
|
|
165
|
+
or DEFAULT_AP_ENDPOINT).rstrip("/")
|
|
166
|
+
job_ids: list[str] = []
|
|
167
|
+
skip = 0
|
|
168
|
+
limit = 500
|
|
169
|
+
|
|
170
|
+
while True:
|
|
171
|
+
url = (f"{endpoint}/groups/{group_id}/jobs"
|
|
172
|
+
f"?skip={skip}&limit={limit}&include_post_process=false")
|
|
173
|
+
headers = {
|
|
174
|
+
"X-API-Key": api_key,
|
|
175
|
+
"Accept": "application/json",
|
|
176
|
+
}
|
|
177
|
+
if cluster:
|
|
178
|
+
headers["X-Cluster"] = cluster
|
|
179
|
+
|
|
180
|
+
req = urllib.request.Request(url, headers=headers, method="GET")
|
|
181
|
+
try:
|
|
182
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
183
|
+
data = json.loads(resp.read().decode())
|
|
184
|
+
except urllib.error.HTTPError as e:
|
|
185
|
+
if e.code == 403:
|
|
186
|
+
raise ArtifactPermissionError(
|
|
187
|
+
f"workspace ACL denied access to group {group_id!r}"
|
|
188
|
+
) from e
|
|
189
|
+
if e.code == 404:
|
|
190
|
+
raise ArtifactNotFoundError(
|
|
191
|
+
f"group {group_id!r} not found"
|
|
192
|
+
) from e
|
|
193
|
+
raise InstanceRepoError(
|
|
194
|
+
f"AP returned HTTP {e.code} for group {group_id!r}"
|
|
195
|
+
) from e
|
|
196
|
+
except urllib.error.URLError as e:
|
|
197
|
+
raise InstanceRepoError(
|
|
198
|
+
f"failed to reach AP at {endpoint}: {e.reason}"
|
|
199
|
+
) from e
|
|
200
|
+
|
|
201
|
+
jobs = data.get("jobs", [])
|
|
202
|
+
if not jobs:
|
|
203
|
+
break
|
|
204
|
+
for j in jobs:
|
|
205
|
+
jid = j.get("job_id")
|
|
206
|
+
if jid:
|
|
207
|
+
job_ids.append(jid)
|
|
208
|
+
if len(jobs) < limit:
|
|
209
|
+
break
|
|
210
|
+
skip += limit
|
|
211
|
+
|
|
212
|
+
return job_ids
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _download_one(transport, bucket: str, job_id: str, output_dir: str) -> str:
|
|
216
|
+
"""下载单个 job 的 artifact 到 output_dir/{job_id}/{文件名}。
|
|
217
|
+
|
|
218
|
+
artifact 文件名不固定:实测两种命名(result.tgz 与 {job_id}.tgz)。
|
|
219
|
+
先 list 前缀目录找非空的 .tgz 对象,兼容所有命名;找不到 → ArtifactNotFoundError。
|
|
220
|
+
|
|
221
|
+
返回本地文件路径。失败时抛异常。
|
|
222
|
+
"""
|
|
223
|
+
import oss2 # 延迟导入,避免无 oss2 环境 import 失败
|
|
224
|
+
|
|
225
|
+
prefix = artifact_prefix(job_id)
|
|
226
|
+
|
|
227
|
+
# 获取 STS(同数据面多 job 共享缓存)
|
|
228
|
+
creds = transport.get_oss_credentials(bucket, prefix)
|
|
229
|
+
auth = oss2.StsAuth(
|
|
230
|
+
creds["access_key_id"],
|
|
231
|
+
creds["access_key_secret"],
|
|
232
|
+
creds["security_token"],
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# 使用 credential 返回的 endpoint(通常是内网)
|
|
236
|
+
endpoint = creds.get("endpoint") or creds.get("oss_endpoint", "")
|
|
237
|
+
if not endpoint:
|
|
238
|
+
raise InstanceRepoError("STS response missing endpoint")
|
|
239
|
+
|
|
240
|
+
bkt = oss2.Bucket(auth, endpoint, bucket)
|
|
241
|
+
|
|
242
|
+
# 1. list 前缀目录,找 artifact tgz 对象(跳过 size=0 的目录占位符)
|
|
243
|
+
key = None
|
|
244
|
+
for obj in oss2.ObjectIterator(bkt, prefix=prefix):
|
|
245
|
+
if obj.size > 0 and obj.key.endswith(".tgz"):
|
|
246
|
+
key = obj.key
|
|
247
|
+
break
|
|
248
|
+
if not key:
|
|
249
|
+
raise ArtifactNotFoundError(
|
|
250
|
+
f"no artifact tgz found under {prefix!r} for job {job_id!r}")
|
|
251
|
+
|
|
252
|
+
# 2. 本地路径(保留 OSS 上的真实文件名)
|
|
253
|
+
job_dir = os.path.join(output_dir, job_id)
|
|
254
|
+
os.makedirs(job_dir, exist_ok=True)
|
|
255
|
+
local_path = os.path.join(job_dir, os.path.basename(key.rstrip("/")))
|
|
256
|
+
|
|
257
|
+
# 3. 下载(带 OSS 限流退避)
|
|
258
|
+
wait = OSS_RETRY_INITIAL_WAIT
|
|
259
|
+
for attempt in range(1, OSS_RETRY_MAX_ATTEMPTS + 1):
|
|
260
|
+
try:
|
|
261
|
+
bkt.get_object_to_file(key, local_path)
|
|
262
|
+
logger.info("downloaded %s → %s", key, local_path)
|
|
263
|
+
return local_path
|
|
264
|
+
except oss2.exceptions.ServerError as e:
|
|
265
|
+
if e.status == 503 and attempt < OSS_RETRY_MAX_ATTEMPTS:
|
|
266
|
+
logger.warning("OSS 503 for %s, retry %d/%d in %.1fs",
|
|
267
|
+
job_id, attempt, OSS_RETRY_MAX_ATTEMPTS, wait)
|
|
268
|
+
import time
|
|
269
|
+
time.sleep(wait)
|
|
270
|
+
wait = min(wait * 2, OSS_RETRY_MAX_WAIT)
|
|
271
|
+
else:
|
|
272
|
+
raise
|
|
273
|
+
return local_path # unreachable, but keeps type checker happy
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
# 公开 API(给 Repo 类调用)
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
def download_artifact(transport, api_key: str, job_id: str,
|
|
281
|
+
cluster: str, output_dir: str = ".",
|
|
282
|
+
ap_endpoint: str | None = None) -> str:
|
|
283
|
+
"""下载单个 job 的 artifact (result.tgz)。
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
transport: SDK Transport 实例(用于 apiserver STS 请求)
|
|
287
|
+
api_key: 用户 API Key(透传给 AP 做 workspace ACL 检查)
|
|
288
|
+
job_id: AP job ID
|
|
289
|
+
cluster: 数据面名
|
|
290
|
+
output_dir: 本地输出目录
|
|
291
|
+
ap_endpoint: AP 控制面 URL(默认从环境变量或常量取)
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
下载文件的本地路径
|
|
295
|
+
"""
|
|
296
|
+
# 1. AP workspace ACL 检查
|
|
297
|
+
fetch_job_info(api_key, job_id, cluster, ap_endpoint)
|
|
298
|
+
|
|
299
|
+
# 2. 确定 bucket
|
|
300
|
+
bucket = resolve_artifact_bucket(cluster)
|
|
301
|
+
|
|
302
|
+
# 3. 下载
|
|
303
|
+
return _download_one(transport, bucket, job_id, output_dir)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def download_artifacts(transport, api_key: str, job_ids: list[str],
|
|
307
|
+
cluster: str, output_dir: str = ".",
|
|
308
|
+
max_concurrent: int = DEFAULT_MAX_CONCURRENT,
|
|
309
|
+
ap_endpoint: str | None = None,
|
|
310
|
+
) -> dict[str, str | Exception]:
|
|
311
|
+
"""批量下载多个 job 的 artifact。
|
|
312
|
+
|
|
313
|
+
并发粒度是跨 job(不是单文件内多线程)。同数据面的多个 job 共享 STS 凭据。
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
transport: SDK Transport 实例
|
|
317
|
+
api_key: 用户 API Key
|
|
318
|
+
job_ids: AP job ID 列表
|
|
319
|
+
cluster: 数据面名
|
|
320
|
+
output_dir: 本地输出目录
|
|
321
|
+
max_concurrent: 最大并发下载数
|
|
322
|
+
ap_endpoint: AP 控制面 URL
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
{job_id: 本地文件路径 或 Exception}
|
|
326
|
+
"""
|
|
327
|
+
bucket = resolve_artifact_bucket(cluster)
|
|
328
|
+
results: dict[str, str | Exception] = {}
|
|
329
|
+
|
|
330
|
+
def _do(jid: str) -> tuple[str, str | Exception]:
|
|
331
|
+
try:
|
|
332
|
+
# AP workspace ACL 检查
|
|
333
|
+
fetch_job_info(api_key, jid, cluster, ap_endpoint)
|
|
334
|
+
# 下载
|
|
335
|
+
path = _download_one(transport, bucket, jid, output_dir)
|
|
336
|
+
return jid, path
|
|
337
|
+
except Exception as e:
|
|
338
|
+
logger.error("failed to download artifact for job %s: %s", jid, e)
|
|
339
|
+
return jid, e
|
|
340
|
+
|
|
341
|
+
with ThreadPoolExecutor(max_workers=max_concurrent) as pool:
|
|
342
|
+
futures = {pool.submit(_do, jid): jid for jid in job_ids}
|
|
343
|
+
for future in as_completed(futures):
|
|
344
|
+
jid, result = future.result()
|
|
345
|
+
results[jid] = result
|
|
346
|
+
|
|
347
|
+
return results
|
|
@@ -380,6 +380,39 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
380
380
|
ds.add_argument("--creator", default=None, help="workflows creator 过滤")
|
|
381
381
|
ds.add_argument("--page", type=int, default=1)
|
|
382
382
|
ds.add_argument("--page-size", type=int, default=20, dest="page_size")
|
|
383
|
+
|
|
384
|
+
# ── Artifact 下载(STS 直连 OSS,workspace ACL 鉴权)──
|
|
385
|
+
art = sub.add_parser("artifact",
|
|
386
|
+
help="AP job artifact 下载(STS 直连 OSS)")
|
|
387
|
+
asub = art.add_subparsers(dest="artifact_cmd", required=True)
|
|
388
|
+
|
|
389
|
+
adl = asub.add_parser("download", help="下载单个 job 的 artifact")
|
|
390
|
+
adl.add_argument("--job-id", required=True, help="AP job ID")
|
|
391
|
+
adl.add_argument("--cluster", default=None,
|
|
392
|
+
help="数据面名(缺省取 SDK profile 绑定的 cluster)")
|
|
393
|
+
adl.add_argument("--output-dir", default=".", dest="output_dir",
|
|
394
|
+
help="本地输出目录")
|
|
395
|
+
adl.add_argument("--ap-endpoint", default=None, dest="ap_endpoint",
|
|
396
|
+
help="AP 控制面 URL(可选)")
|
|
397
|
+
|
|
398
|
+
adlm = asub.add_parser("download-many", help="批量下载多个 job 的 artifact")
|
|
399
|
+
adlm.add_argument("--job-ids", required=True, dest="job_ids",
|
|
400
|
+
help="逗号分隔的 job ID 列表")
|
|
401
|
+
adlm.add_argument("--cluster", default=None)
|
|
402
|
+
adlm.add_argument("--output-dir", default=".", dest="output_dir")
|
|
403
|
+
adlm.add_argument("--concurrent", type=int, default=8,
|
|
404
|
+
help="最大并发下载数(默认 8)")
|
|
405
|
+
adlm.add_argument("--ap-endpoint", default=None, dest="ap_endpoint")
|
|
406
|
+
|
|
407
|
+
adlg = asub.add_parser("download-group",
|
|
408
|
+
help="下载某 group 下全部 job 的 artifact")
|
|
409
|
+
adlg.add_argument("--group-id", required=True, dest="group_id",
|
|
410
|
+
help="AP group ID")
|
|
411
|
+
adlg.add_argument("--cluster", default=None)
|
|
412
|
+
adlg.add_argument("--output-dir", default=".", dest="output_dir")
|
|
413
|
+
adlg.add_argument("--concurrent", type=int, default=8)
|
|
414
|
+
adlg.add_argument("--ap-endpoint", default=None, dest="ap_endpoint")
|
|
415
|
+
|
|
383
416
|
return ap
|
|
384
417
|
|
|
385
418
|
|
|
@@ -1057,6 +1090,48 @@ def main(argv=None, *, repo: Repo | None = None, out=None) -> int:
|
|
|
1057
1090
|
print(f"page={res.page}/{_page_count(res)} total={res.total}", file=out)
|
|
1058
1091
|
return 0
|
|
1059
1092
|
|
|
1093
|
+
if args.cmd == "artifact":
|
|
1094
|
+
if args.artifact_cmd == "download":
|
|
1095
|
+
path = r.download_artifact(
|
|
1096
|
+
args.job_id, cluster=args.cluster,
|
|
1097
|
+
output_dir=args.output_dir,
|
|
1098
|
+
ap_endpoint=args.ap_endpoint)
|
|
1099
|
+
print(str(path), file=out)
|
|
1100
|
+
return 0
|
|
1101
|
+
if args.artifact_cmd == "download-many":
|
|
1102
|
+
job_ids = [j.strip() for j in args.job_ids.split(",") if j.strip()]
|
|
1103
|
+
results = r.download_artifacts(
|
|
1104
|
+
job_ids, cluster=args.cluster,
|
|
1105
|
+
output_dir=args.output_dir,
|
|
1106
|
+
max_concurrent=args.concurrent,
|
|
1107
|
+
ap_endpoint=args.ap_endpoint)
|
|
1108
|
+
ok, fail = 0, 0
|
|
1109
|
+
for jid, res in results.items():
|
|
1110
|
+
if isinstance(res, Exception):
|
|
1111
|
+
print(f"FAIL {jid}: {res}", file=out)
|
|
1112
|
+
fail += 1
|
|
1113
|
+
else:
|
|
1114
|
+
print(f"OK {jid}: {res}", file=out)
|
|
1115
|
+
ok += 1
|
|
1116
|
+
print(f"\ntotal={len(results)} ok={ok} failed={fail}", file=out)
|
|
1117
|
+
return 1 if fail else 0
|
|
1118
|
+
if args.artifact_cmd == "download-group":
|
|
1119
|
+
results = r.download_artifacts_from_group(
|
|
1120
|
+
args.group_id, cluster=args.cluster,
|
|
1121
|
+
output_dir=args.output_dir,
|
|
1122
|
+
max_concurrent=args.concurrent,
|
|
1123
|
+
ap_endpoint=args.ap_endpoint)
|
|
1124
|
+
ok, fail = 0, 0
|
|
1125
|
+
for jid, res in results.items():
|
|
1126
|
+
if isinstance(res, Exception):
|
|
1127
|
+
print(f"FAIL {jid}: {res}", file=out)
|
|
1128
|
+
fail += 1
|
|
1129
|
+
else:
|
|
1130
|
+
print(f"OK {jid}: {res}", file=out)
|
|
1131
|
+
ok += 1
|
|
1132
|
+
print(f"\ntotal={len(results)} ok={ok} failed={fail}", file=out)
|
|
1133
|
+
return 1 if fail else 0
|
|
1134
|
+
|
|
1060
1135
|
return 2 # pragma: no cover - argparse 已保证 cmd 合法
|
|
1061
1136
|
|
|
1062
1137
|
|
|
@@ -57,10 +57,14 @@ class InstancesClient:
|
|
|
57
57
|
self._ingester = ingester
|
|
58
58
|
|
|
59
59
|
def _after_upload(self, register: bool, dataset: str,
|
|
60
|
-
version: str) -> dict | None:
|
|
60
|
+
version: str, split: str = "default") -> dict | None:
|
|
61
61
|
"""register=True:上传成功后触发一次 ingest 写库,返回 ingest job 摘要。
|
|
62
62
|
|
|
63
63
|
register=False 返回 None(只传 OSS,由调用方决定何时 ingest)。
|
|
64
|
+
|
|
65
|
+
``split`` 必须透传给 ingest:split_first + 无 version 时服务端要求显式
|
|
66
|
+
splits,否则 ingest 报 SchemaError——上传成功但 split 记录不落库,文件
|
|
67
|
+
孤悬 OSS(真机锤实:``instance push --split abc`` 无 --version 场景)。
|
|
64
68
|
"""
|
|
65
69
|
if not register:
|
|
66
70
|
return None
|
|
@@ -69,7 +73,7 @@ class InstancesClient:
|
|
|
69
73
|
"register=True 需要 ingest 回调:请通过 Repo() 构造客户端"
|
|
70
74
|
"(已注入 versions.ingest),或显式传入 ingester=...;"
|
|
71
75
|
"直接构造 InstancesClient 且未注入时请用 register=False 只传 OSS")
|
|
72
|
-
return self._ingester(dataset, version)
|
|
76
|
+
return self._ingester(dataset, version, split=split)
|
|
73
77
|
|
|
74
78
|
def _layout(self, metadata_model: str | None = None) -> Layout:
|
|
75
79
|
"""按(显式或 profile 的)元数据模型构造布局策略。
|
|
@@ -387,7 +391,7 @@ class InstancesClient:
|
|
|
387
391
|
# ⑤ 写库:register=True 时经 ingest 扫描入库(替代缺失的 :commit 三段式收尾)。
|
|
388
392
|
# ingest 幂等(重扫 OSS),skip 与否都由服务端按 OSS 实际字节重算 digest,
|
|
389
393
|
# 不再有「本地 digest 覆盖别家生产者」的 skip 分支歧义。
|
|
390
|
-
ingest_result = self._after_upload(register, dataset, version)
|
|
394
|
+
ingest_result = self._after_upload(register, dataset, version, split=split)
|
|
391
395
|
digests = {"content.tgz": digest}
|
|
392
396
|
return PushResult(instance_id=instance_id, uploaded=not skipped,
|
|
393
397
|
skipped=skipped, image_pushed=image_pushed,
|
|
@@ -442,7 +446,7 @@ class InstancesClient:
|
|
|
442
446
|
f"{instance_id}: OSS 回读校验失败——上传后未在 OSS 找到 {idx_key}")
|
|
443
447
|
|
|
444
448
|
# ④ 写库:register=True 时经 ingest 扫描入库(替代缺失的 :commit 收尾)
|
|
445
|
-
ingest_result = self._after_upload(register, dataset, version)
|
|
449
|
+
ingest_result = self._after_upload(register, dataset, version, split=split)
|
|
446
450
|
|
|
447
451
|
return PushResult(instance_id=instance_id, uploaded=not skipped,
|
|
448
452
|
skipped=skipped, image_pushed=False,
|
|
@@ -533,7 +537,7 @@ class InstancesClient:
|
|
|
533
537
|
f"{instance_id}: OSS 回读校验失败——上传后未在 OSS 找到 {idx_key}")
|
|
534
538
|
|
|
535
539
|
# ④ 写库:register=True 时经 ingest 扫描入库(替代缺失的 :commit 收尾)
|
|
536
|
-
ingest_result = self._after_upload(register, dataset, version)
|
|
540
|
+
ingest_result = self._after_upload(register, dataset, version, split=split)
|
|
537
541
|
|
|
538
542
|
return PushResult(instance_id=instance_id, uploaded=not skipped,
|
|
539
543
|
skipped=skipped, image_pushed=False,
|
|
@@ -650,21 +654,28 @@ class InstancesClient:
|
|
|
650
654
|
# continue_on_error=False 时 map_concurrent 已对失败项抛 ItemError,能走到
|
|
651
655
|
# 这里说明整批成功;True 时对成功子集 ingest(与 deliver 语义一致)。
|
|
652
656
|
if register:
|
|
653
|
-
self._after_upload(True, dataset, version)
|
|
657
|
+
self._after_upload(True, dataset, version, split=split)
|
|
654
658
|
return results
|
|
655
659
|
|
|
656
660
|
def pull(self, dataset: str, version: str, instance_id: str, dest_dir,
|
|
657
661
|
split: str = "default", *, verify: bool = True) -> Path:
|
|
658
|
-
"""下载实例内容。自动检测格式:harbor 下载 content.tgz,swe 下载 {id}.json
|
|
662
|
+
"""下载实例内容。自动检测格式:harbor 下载 content.tgz,swe 下载 {id}.json,
|
|
663
|
+
custom 下载 {id}.json + assets 目录。
|
|
659
664
|
|
|
660
665
|
格式判断优先级:
|
|
661
|
-
1. verify=True 且 registry 有元数据 → 从 manifest
|
|
662
|
-
|
|
663
|
-
|
|
666
|
+
1. verify=True 且 registry 有元数据 → 从 manifest 判断
|
|
667
|
+
(有 tgz=harbor,仅 json=swe/custom,需进一步探测)
|
|
668
|
+
2. 无元数据或 manifest 无法区分 swe/custom → 下载 metadata JSON 检查
|
|
669
|
+
metadata_model 字段(custom → 下载 assets 目录;否则 swe)
|
|
670
|
+
3. metadata JSON 不存在 → fallback 到 harbor(下载 content.tgz)
|
|
664
671
|
"""
|
|
665
672
|
validate_instance_id(instance_id, source="pull()")
|
|
666
673
|
expected = None
|
|
667
674
|
fmt = None # None = 未确定,需要探测
|
|
675
|
+
lay = self._layout()
|
|
676
|
+
# 已下载的元数据字节(探测阶段下载一次,swe/custom 路径复用,避免二次网络往返)。
|
|
677
|
+
meta_bytes: bytes | None = None
|
|
678
|
+
|
|
668
679
|
if verify:
|
|
669
680
|
meta = self._reg.get(dataset, version, instance_id, split)
|
|
670
681
|
if meta is not None and meta.manifest:
|
|
@@ -677,19 +688,35 @@ class InstancesClient:
|
|
|
677
688
|
if has_tgz:
|
|
678
689
|
fmt = "harbor"
|
|
679
690
|
else:
|
|
680
|
-
# manifest
|
|
691
|
+
# manifest 有 json entry 但无 tgz → swe 或 custom(需进一步探测)。
|
|
692
|
+
# custom 推的 manifest 由 ingest 扫描生成,与 swe 同形态;
|
|
693
|
+
# 唯一区分手段是读 metadata JSON 检查 metadata_model 字段。
|
|
681
694
|
for m in meta.manifest:
|
|
682
695
|
if m.content_format == "json":
|
|
683
|
-
fmt = "swe"
|
|
696
|
+
fmt = "swe" # 暂定 swe,后续探测可能修正为 custom
|
|
684
697
|
expected = m.content_digest
|
|
685
698
|
break
|
|
686
|
-
|
|
687
|
-
# 无法从 manifest
|
|
688
|
-
|
|
689
|
-
|
|
699
|
+
|
|
700
|
+
# 无法从 manifest 确定 harbor → 探测 OSS 区分 swe/custom 或 fallback harbor。
|
|
701
|
+
idx_key = lay.metadata_key(dataset, instance_id, version=version, split=split)
|
|
702
|
+
sts_prefix = lay.sts_prefix(dataset)
|
|
703
|
+
|
|
704
|
+
# ── 探测辅助:下载 metadata JSON 到临时文件并解析,区分 swe / custom ──
|
|
705
|
+
def _probe_meta_json() -> bytes | None:
|
|
706
|
+
"""下载 metadata JSON 并返回字节;不存在或解析失败时返回 None。"""
|
|
707
|
+
tmp_dest = Path(dest_dir) / f".{instance_id}.json.probe"
|
|
708
|
+
tmp_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
709
|
+
try:
|
|
710
|
+
self._content.get_object(
|
|
711
|
+
idx_key, tmp_dest, sts_prefix=sts_prefix, dataset=dataset)
|
|
712
|
+
data = tmp_dest.read_bytes()
|
|
713
|
+
return data
|
|
714
|
+
except Exception: # noqa: BLE001 — 下载失败视为不存在
|
|
715
|
+
return None
|
|
716
|
+
finally:
|
|
717
|
+
tmp_dest.unlink(missing_ok=True)
|
|
718
|
+
|
|
690
719
|
if fmt is None:
|
|
691
|
-
idx_key = lay.metadata_key(dataset, instance_id, version=version, split=split)
|
|
692
|
-
sts_prefix = lay.sts_prefix(dataset)
|
|
693
720
|
download_probe = getattr(self._content, "_download_probe", None)
|
|
694
721
|
if download_probe is not None:
|
|
695
722
|
exists = download_probe(
|
|
@@ -698,22 +725,97 @@ class InstancesClient:
|
|
|
698
725
|
exists = self._content.object_exists(
|
|
699
726
|
idx_key, sts_prefix=sts_prefix)
|
|
700
727
|
if exists:
|
|
701
|
-
|
|
728
|
+
# metadata JSON 存在 → 下载并解析以区分 swe / custom
|
|
729
|
+
fmt = "swe" # 默认;下载后可能修正为 custom
|
|
730
|
+
probed = _probe_meta_json()
|
|
731
|
+
if probed is not None:
|
|
732
|
+
meta_bytes = probed
|
|
733
|
+
try:
|
|
734
|
+
meta_doc = json.loads(meta_bytes)
|
|
735
|
+
if (isinstance(meta_doc, dict)
|
|
736
|
+
and meta_doc.get("metadata_model") == "custom"):
|
|
737
|
+
fmt = "custom"
|
|
738
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
739
|
+
pass # 无法解析 → 保持 swe
|
|
702
740
|
else:
|
|
703
741
|
fmt = "harbor"
|
|
704
742
|
|
|
743
|
+
# 若 manifest 判定为 swe 但实际可能是 custom(两者 manifest 形态相同),
|
|
744
|
+
# 下载 metadata JSON 做最终确认。
|
|
745
|
+
if fmt == "swe" and meta_bytes is None:
|
|
746
|
+
probed = _probe_meta_json()
|
|
747
|
+
if probed is not None:
|
|
748
|
+
meta_bytes = probed
|
|
749
|
+
try:
|
|
750
|
+
meta_doc = json.loads(meta_bytes)
|
|
751
|
+
if (isinstance(meta_doc, dict)
|
|
752
|
+
and meta_doc.get("metadata_model") == "custom"):
|
|
753
|
+
fmt = "custom"
|
|
754
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
755
|
+
pass
|
|
756
|
+
|
|
757
|
+
if fmt == "custom":
|
|
758
|
+
dest = Path(dest_dir) / f"{instance_id}.json"
|
|
759
|
+
if meta_bytes is not None:
|
|
760
|
+
# 已在探测阶段下载,直接写入本地(避免二次网络往返)。
|
|
761
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
762
|
+
dest.write_bytes(meta_bytes)
|
|
763
|
+
else:
|
|
764
|
+
self._content.get_object(
|
|
765
|
+
idx_key, dest, expected_digest=expected,
|
|
766
|
+
sts_prefix=sts_prefix, dataset=dataset)
|
|
767
|
+
# 下载资产目录({split}-assets/{id}/ 前缀下的所有文件)
|
|
768
|
+
self._download_custom_assets(
|
|
769
|
+
dataset, instance_id, split, dest_dir, lay, sts_prefix)
|
|
770
|
+
return dest
|
|
771
|
+
|
|
705
772
|
if fmt == "swe":
|
|
706
773
|
dest = Path(dest_dir) / f"{instance_id}.json"
|
|
707
|
-
|
|
774
|
+
if meta_bytes is not None:
|
|
775
|
+
# 已在探测阶段下载,直接写入本地。
|
|
776
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
777
|
+
dest.write_bytes(meta_bytes)
|
|
778
|
+
return dest
|
|
708
779
|
return self._content.get_object(
|
|
709
780
|
idx_key, dest, expected_digest=expected,
|
|
710
|
-
sts_prefix=
|
|
781
|
+
sts_prefix=sts_prefix,
|
|
711
782
|
dataset=dataset)
|
|
783
|
+
|
|
712
784
|
# harbor:下载 content.tgz
|
|
713
785
|
dest = Path(dest_dir) / instance_id / "content.tgz"
|
|
714
786
|
return self._content.get(dataset, version, instance_id, split, dest,
|
|
715
787
|
expected_digest=expected, layout=lay)
|
|
716
788
|
|
|
789
|
+
def _download_custom_assets(self, dataset: str, instance_id: str,
|
|
790
|
+
split: str, dest_dir, lay, sts_prefix: str
|
|
791
|
+
) -> list[Path]:
|
|
792
|
+
"""下载 custom 格式的资产目录({split}-assets/{id}/ 前缀下的所有文件)。
|
|
793
|
+
|
|
794
|
+
资产文件按相对路径还原到 ``{dest_dir}/{instance_id}/`` 下。返回已下载
|
|
795
|
+
的本地文件路径列表。无资产时返回空列表(不报错)。
|
|
796
|
+
"""
|
|
797
|
+
asset_prefix = (f"{lay.dataset_root(dataset)}"
|
|
798
|
+
f"{split}-assets/{instance_id}/")
|
|
799
|
+
list_fn = getattr(self._content, "list_objects", None)
|
|
800
|
+
if list_fn is None:
|
|
801
|
+
return []
|
|
802
|
+
keys = list_fn(asset_prefix)
|
|
803
|
+
if not keys:
|
|
804
|
+
return []
|
|
805
|
+
downloaded: list[Path] = []
|
|
806
|
+
dest_root = Path(dest_dir) / instance_id
|
|
807
|
+
for key in keys:
|
|
808
|
+
rel = key[len(asset_prefix):]
|
|
809
|
+
if not rel or rel.endswith("/"):
|
|
810
|
+
continue # 跳过空键和目录占位符
|
|
811
|
+
local_path = dest_root / rel
|
|
812
|
+
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
813
|
+
self._content.get_object(
|
|
814
|
+
key, local_path,
|
|
815
|
+
sts_prefix=sts_prefix, dataset=dataset)
|
|
816
|
+
downloaded.append(local_path)
|
|
817
|
+
return downloaded
|
|
818
|
+
|
|
717
819
|
def pull_many(self, instance_ids: list[str], dataset: str, version: str,
|
|
718
820
|
dest_dir, *, split: str = "default",
|
|
719
821
|
concurrency: int = 8, continue_on_error: bool = False,
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
"""repo.py — Repo Facade:组装 transport + store + 三资源客户端。"""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
import logging
|
|
4
5
|
import os
|
|
5
6
|
import posixpath
|
|
6
7
|
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
7
10
|
from . import bootstrap
|
|
8
11
|
from . import paths as P
|
|
9
12
|
from .clients.config import (
|
|
@@ -297,7 +300,6 @@ class Repo:
|
|
|
297
300
|
"""
|
|
298
301
|
from pathlib import Path as _Path
|
|
299
302
|
import shutil as _shutil
|
|
300
|
-
from . import release as _R
|
|
301
303
|
from .clients.config import load_profile
|
|
302
304
|
from .validate import detect_format
|
|
303
305
|
|
|
@@ -460,6 +462,85 @@ class Repo:
|
|
|
460
462
|
pfx += self._clean_user_data_subpath(subpath)
|
|
461
463
|
return self._content.list_objects(pfx)
|
|
462
464
|
|
|
465
|
+
# ── Artifact 下载(STS 直连 OSS,workspace ACL 鉴权)──
|
|
466
|
+
|
|
467
|
+
def download_artifact(self, job_id: str, cluster: str | None = None,
|
|
468
|
+
output_dir: str = ".", *,
|
|
469
|
+
ap_endpoint: str | None = None) -> str:
|
|
470
|
+
"""下载单个 job 的 artifact (result.tgz) 到本地。
|
|
471
|
+
|
|
472
|
+
鉴权:AP workspace ACL(SDK 调 AP GET /api/jobs/{id} 时自动检查)。
|
|
473
|
+
并发模型:单 job = 单文件单线程。
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
job_id: AP job ID
|
|
477
|
+
cluster: 数据面名(默认使用 SDK profile 绑定的 cluster)
|
|
478
|
+
output_dir: 本地输出目录
|
|
479
|
+
ap_endpoint: AP 控制面 URL(可选,默认从环境变量或内置常量取)
|
|
480
|
+
|
|
481
|
+
Returns:
|
|
482
|
+
下载文件的本地路径
|
|
483
|
+
"""
|
|
484
|
+
from .artifacts import download_artifact as _dl
|
|
485
|
+
cluster = cluster or self.profile.cluster or ""
|
|
486
|
+
return _dl(self.transport, self.transport.token, job_id,
|
|
487
|
+
cluster, output_dir, ap_endpoint)
|
|
488
|
+
|
|
489
|
+
def download_artifacts(self, job_ids: list[str],
|
|
490
|
+
cluster: str | None = None,
|
|
491
|
+
output_dir: str = ".", *,
|
|
492
|
+
max_concurrent: int = 8,
|
|
493
|
+
ap_endpoint: str | None = None,
|
|
494
|
+
) -> dict[str, str | Exception]:
|
|
495
|
+
"""批量下载多个 job 的 artifact。
|
|
496
|
+
|
|
497
|
+
并发粒度是跨 job(不是单文件内多线程)。同数据面的多个 job 共享 STS 凭据。
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
job_ids: AP job ID 列表
|
|
501
|
+
cluster: 数据面名
|
|
502
|
+
output_dir: 本地输出目录
|
|
503
|
+
max_concurrent: 最大并发下载数(默认 8)
|
|
504
|
+
ap_endpoint: AP 控制面 URL
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
{job_id: 本地文件路径 或 Exception}
|
|
508
|
+
"""
|
|
509
|
+
from .artifacts import download_artifacts as _dl_many
|
|
510
|
+
cluster = cluster or self.profile.cluster or ""
|
|
511
|
+
return _dl_many(self.transport, self.transport.token, job_ids,
|
|
512
|
+
cluster, output_dir, max_concurrent, ap_endpoint)
|
|
513
|
+
|
|
514
|
+
def download_artifacts_from_group(self, group_id: str,
|
|
515
|
+
cluster: str | None = None,
|
|
516
|
+
output_dir: str = ".", *,
|
|
517
|
+
max_concurrent: int = 8,
|
|
518
|
+
ap_endpoint: str | None = None,
|
|
519
|
+
) -> dict[str, str | Exception]:
|
|
520
|
+
"""下载某个 group 下全部 job 的 artifact。
|
|
521
|
+
|
|
522
|
+
先调 AP GET /groups/{group_id}/jobs 分页收集 job_id,再批量下载。
|
|
523
|
+
|
|
524
|
+
Args:
|
|
525
|
+
group_id: AP group ID
|
|
526
|
+
cluster: 数据面名
|
|
527
|
+
output_dir: 本地输出目录
|
|
528
|
+
max_concurrent: 最大并发下载数
|
|
529
|
+
ap_endpoint: AP 控制面 URL
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
{job_id: 本地文件路径 或 Exception}
|
|
533
|
+
"""
|
|
534
|
+
from .artifacts import fetch_group_job_ids, download_artifacts as _dl_many
|
|
535
|
+
cluster = cluster or self.profile.cluster or ""
|
|
536
|
+
job_ids = fetch_group_job_ids(
|
|
537
|
+
self.transport.token, group_id, cluster, ap_endpoint)
|
|
538
|
+
if not job_ids:
|
|
539
|
+
logger.warning("group %s has no jobs", group_id)
|
|
540
|
+
return {}
|
|
541
|
+
return _dl_many(self.transport, self.transport.token, job_ids,
|
|
542
|
+
cluster, output_dir, max_concurrent, ap_endpoint)
|
|
543
|
+
|
|
463
544
|
# ── 用户数据 ACL(role-bindings 接口,resource_type=user_data)──
|
|
464
545
|
_USER_DATA_ROLE_MAP = {
|
|
465
546
|
"reader": "built-in:user-data-reader",
|
|
@@ -17,6 +17,7 @@ from .._routing import (
|
|
|
17
17
|
_plan_oss_route,
|
|
18
18
|
_probe_private_endpoint,
|
|
19
19
|
)
|
|
20
|
+
from ..concurrency import ItemError, map_concurrent
|
|
20
21
|
from ..content import sha256_file, verify_file_digest
|
|
21
22
|
from ..errors import DigestMismatch, PrivateNetworkRequired, TransportError
|
|
22
23
|
from ..retry import retry_transient
|
|
@@ -244,6 +245,13 @@ class OssContentStore(BaseContentStore):
|
|
|
244
245
|
if not route.standard:
|
|
245
246
|
raise _unconfirmed_private_endpoint_error(original_endpoint)
|
|
246
247
|
endpoint = route.private_endpoint
|
|
248
|
+
# Fail-fast: 3 秒探测私网可达性,不通立即报错,避免公网环境下
|
|
249
|
+
# 傻等 TCP 超时(20s × 多轮重试 = 几分钟挂死)。下载仍不 fallback
|
|
250
|
+
# 公网(安全约束),但能快速告知调用方需要私网环境。
|
|
251
|
+
if not self._probe_private_endpoint(endpoint):
|
|
252
|
+
raise _private_endpoint_error(
|
|
253
|
+
endpoint, ConnectionRefusedError(
|
|
254
|
+
f"TCP probe failed for {endpoint} within 3s"))
|
|
247
255
|
try:
|
|
248
256
|
bucket = self._open_bucket(
|
|
249
257
|
sts, prefix, endpoint, private_download=True)
|
|
@@ -384,6 +392,109 @@ class OssContentStore(BaseContentStore):
|
|
|
384
392
|
return self._execute_oss(
|
|
385
393
|
_OperationIntent.NON_DOWNLOAD, prefix, None, list_all)
|
|
386
394
|
|
|
395
|
+
# ── directory-level helpers(自定义格式实例 = 目录级上传/下载)──
|
|
396
|
+
def put_directory(self, local_dir: Path, oss_prefix: str, *,
|
|
397
|
+
overwrite: bool = False,
|
|
398
|
+
sts_prefix: str | None = None,
|
|
399
|
+
dataset: str | None = None,
|
|
400
|
+
concurrency: int = 8) -> dict:
|
|
401
|
+
"""Upload all files under *local_dir* to OSS under *oss_prefix*.
|
|
402
|
+
|
|
403
|
+
Each file is uploaded via :meth:`put_object` so every transfer gets
|
|
404
|
+
proper STS credentials and endpoint routing. Files are uploaded in
|
|
405
|
+
parallel using :func:`~instance_repo.concurrency.map_concurrent`.
|
|
406
|
+
|
|
407
|
+
Returns a summary dict::
|
|
408
|
+
|
|
409
|
+
{"file_count": int, "total_bytes": int, "keys": list[str]}
|
|
410
|
+
"""
|
|
411
|
+
local_dir = Path(local_dir)
|
|
412
|
+
if not local_dir.is_dir():
|
|
413
|
+
raise FileNotFoundError(f"local directory not found: {local_dir}")
|
|
414
|
+
|
|
415
|
+
# Normalize prefix to always end with "/" so keys are proper children.
|
|
416
|
+
if oss_prefix and not oss_prefix.endswith("/"):
|
|
417
|
+
oss_prefix += "/"
|
|
418
|
+
|
|
419
|
+
# Collect (local_path, oss_key) pairs — sorted for determinism.
|
|
420
|
+
files: list[tuple[Path, str]] = sorted(
|
|
421
|
+
((p, oss_prefix + str(p.relative_to(local_dir)))
|
|
422
|
+
for p in local_dir.rglob("*") if p.is_file()),
|
|
423
|
+
key=lambda t: t[1],
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
total_bytes = sum(p.stat().st_size for p, _ in files)
|
|
427
|
+
|
|
428
|
+
def _upload_one(item: tuple[Path, str]) -> str:
|
|
429
|
+
path, key = item
|
|
430
|
+
self.put_object(key, path, overwrite=overwrite,
|
|
431
|
+
sts_prefix=sts_prefix, dataset=dataset)
|
|
432
|
+
return key
|
|
433
|
+
|
|
434
|
+
try:
|
|
435
|
+
keys = map_concurrent(_upload_one, files, concurrency=concurrency)
|
|
436
|
+
except ItemError as exc:
|
|
437
|
+
raise exc.error from exc
|
|
438
|
+
return {
|
|
439
|
+
"file_count": len(files),
|
|
440
|
+
"total_bytes": total_bytes,
|
|
441
|
+
"keys": keys,
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
def get_directory(self, oss_prefix: str, local_dir: Path, *,
|
|
445
|
+
expected_digests: dict[str, str] | None = None,
|
|
446
|
+
sts_prefix: str | None = None,
|
|
447
|
+
dataset: str | None = None,
|
|
448
|
+
concurrency: int = 8) -> Path:
|
|
449
|
+
"""Download all objects under *oss_prefix* to *local_dir*.
|
|
450
|
+
|
|
451
|
+
Objects are listed via :meth:`list_objects` and then downloaded in
|
|
452
|
+
parallel. Each file is fetched via :meth:`get_object` so every
|
|
453
|
+
transfer gets proper STS credentials and private-download routing.
|
|
454
|
+
|
|
455
|
+
*expected_digests* optionally maps **relative paths** (e.g.
|
|
456
|
+
``"subdir/file.bin"``) to their expected content digest strings
|
|
457
|
+
(``"sha256:<hex>"`` or bare md5). Any mismatch raises
|
|
458
|
+
:class:`~instance_repo.errors.DigestMismatch`.
|
|
459
|
+
|
|
460
|
+
Returns the *local_dir* path.
|
|
461
|
+
"""
|
|
462
|
+
local_dir = Path(local_dir)
|
|
463
|
+
local_dir.mkdir(parents=True, exist_ok=True)
|
|
464
|
+
|
|
465
|
+
# Normalize prefix for consistent key slicing.
|
|
466
|
+
if oss_prefix and not oss_prefix.endswith("/"):
|
|
467
|
+
oss_prefix += "/"
|
|
468
|
+
|
|
469
|
+
keys = self.list_objects(oss_prefix)
|
|
470
|
+
|
|
471
|
+
def _download_one(key: str) -> Path:
|
|
472
|
+
rel = key[len(oss_prefix):]
|
|
473
|
+
if not rel: # skip the prefix "directory marker" itself
|
|
474
|
+
return local_dir
|
|
475
|
+
dest = local_dir / rel
|
|
476
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
477
|
+
self._execute_oss(
|
|
478
|
+
_OperationIntent.DOWNLOAD,
|
|
479
|
+
sts_prefix or oss_prefix,
|
|
480
|
+
dataset,
|
|
481
|
+
lambda bucket: bucket.get_object_to_file(key, str(dest)),
|
|
482
|
+
)
|
|
483
|
+
# Per-file digest check.
|
|
484
|
+
if expected_digests is not None:
|
|
485
|
+
expected = expected_digests.get(rel)
|
|
486
|
+
if expected is not None and not verify_file_digest(dest, expected):
|
|
487
|
+
raise DigestMismatch(
|
|
488
|
+
f"{key}: content digest mismatch "
|
|
489
|
+
f"(expected {expected}, got {sha256_file(dest)})")
|
|
490
|
+
return dest
|
|
491
|
+
|
|
492
|
+
try:
|
|
493
|
+
map_concurrent(_download_one, keys, concurrency=concurrency)
|
|
494
|
+
except ItemError as exc:
|
|
495
|
+
raise exc.error from exc
|
|
496
|
+
return local_dir
|
|
497
|
+
|
|
387
498
|
# ── 实例内容便捷封装(content.tgz + {id}.json) ──
|
|
388
499
|
def exists(self, instance_id: str, dataset: str, version: str,
|
|
389
500
|
split: str = "default", *, layout=None) -> bool:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{instance_repo-1.0.9.dev6 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|