instance-repo 1.1.0__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.
Files changed (48) hide show
  1. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/PKG-INFO +1 -1
  2. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/__init__.py +1 -1
  3. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/_site_defaults.py +12 -0
  4. instance_repo-1.1.0.dev0/instance_repo/artifacts.py +347 -0
  5. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/cli.py +75 -0
  6. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/clients/instances.py +10 -6
  7. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/repo.py +82 -0
  8. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/store/oss.py +7 -0
  9. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/PKG-INFO +1 -1
  10. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/SOURCES.txt +1 -0
  11. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/pyproject.toml +1 -1
  12. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/README.md +0 -0
  13. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/_routing.py +0 -0
  14. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/bootstrap.py +0 -0
  15. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/cache.py +0 -0
  16. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/clients/__init__.py +0 -0
  17. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/clients/benchmarks.py +0 -0
  18. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/clients/config.py +0 -0
  19. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/clients/datasets.py +0 -0
  20. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/clients/images.py +0 -0
  21. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/clients/reports.py +0 -0
  22. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/clients/scaffold.py +0 -0
  23. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/clients/splits.py +0 -0
  24. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/clients/versions.py +0 -0
  25. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/clients/workflows.py +0 -0
  26. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/concurrency.py +0 -0
  27. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/content.py +0 -0
  28. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/endpoints.py +0 -0
  29. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/errors.py +0 -0
  30. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/image.py +0 -0
  31. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/layout.py +0 -0
  32. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/loader.py +0 -0
  33. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/models.py +0 -0
  34. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/paths.py +0 -0
  35. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/release.py +0 -0
  36. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/retry.py +0 -0
  37. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/store/__init__.py +0 -0
  38. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/store/acr.py +0 -0
  39. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/store/base.py +0 -0
  40. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/store/registry.py +0 -0
  41. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/tasktoml.py +0 -0
  42. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/transport.py +0 -0
  43. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/validate.py +0 -0
  44. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/dependency_links.txt +0 -0
  45. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/entry_points.txt +0 -0
  46. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/requires.txt +0 -0
  47. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/top_level.txt +0 -0
  48. {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: instance-repo
3
- Version: 1.1.0
3
+ Version: 1.1.0.dev0
4
4
  Summary: InstanceRepo SDK — 评测 Instance 统一存储客户端(SDK-only;控制面由 apiserver 承载)
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -18,7 +18,7 @@ from .errors import (
18
18
  TransportError,
19
19
  )
20
20
 
21
- __version__ = "1.1.0"
21
+ __version__ = "1.1.0.dev0"
22
22
 
23
23
  __all__ = [
24
24
  "Repo", "load_instances",
@@ -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,7 +654,7 @@ 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,
@@ -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 (
@@ -459,6 +462,85 @@ class Repo:
459
462
  pfx += self._clean_user_data_subpath(subpath)
460
463
  return self._content.list_objects(pfx)
461
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
+
462
544
  # ── 用户数据 ACL(role-bindings 接口,resource_type=user_data)──
463
545
  _USER_DATA_ROLE_MAP = {
464
546
  "reader": "built-in:user-data-reader",
@@ -245,6 +245,13 @@ class OssContentStore(BaseContentStore):
245
245
  if not route.standard:
246
246
  raise _unconfirmed_private_endpoint_error(original_endpoint)
247
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"))
248
255
  try:
249
256
  bucket = self._open_bucket(
250
257
  sts, prefix, endpoint, private_download=True)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: instance-repo
3
- Version: 1.1.0
3
+ Version: 1.1.0.dev0
4
4
  Summary: InstanceRepo SDK — 评测 Instance 统一存储客户端(SDK-only;控制面由 apiserver 承载)
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -3,6 +3,7 @@ pyproject.toml
3
3
  instance_repo/__init__.py
4
4
  instance_repo/_routing.py
5
5
  instance_repo/_site_defaults.py
6
+ instance_repo/artifacts.py
6
7
  instance_repo/bootstrap.py
7
8
  instance_repo/cache.py
8
9
  instance_repo/cli.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "instance-repo"
7
- version = "1.1.0"
7
+ version = "1.1.0.dev0"
8
8
  description = "InstanceRepo SDK — 评测 Instance 统一存储客户端(SDK-only;控制面由 apiserver 承载)"
9
9
  requires-python = ">=3.10"
10
10
  readme = "README.md"