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.
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/PKG-INFO +1 -1
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/__init__.py +1 -1
- {instance_repo-1.1.0 → 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.1.0 → instance_repo-1.1.0.dev0}/instance_repo/cli.py +75 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/clients/instances.py +10 -6
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/repo.py +82 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/store/oss.py +7 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/PKG-INFO +1 -1
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/SOURCES.txt +1 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/pyproject.toml +1 -1
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/README.md +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/_routing.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/bootstrap.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/cache.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/clients/__init__.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/clients/benchmarks.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/clients/config.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/clients/datasets.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/clients/images.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/clients/reports.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/clients/scaffold.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/clients/splits.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/clients/versions.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/clients/workflows.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/concurrency.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/content.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/endpoints.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/errors.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/image.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/layout.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/loader.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/models.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/paths.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/release.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/retry.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/store/__init__.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/store/acr.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/store/base.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/store/registry.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/tasktoml.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/transport.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo/validate.py +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/dependency_links.txt +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/entry_points.txt +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/requires.txt +0 -0
- {instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/top_level.txt +0 -0
- {instance_repo-1.1.0 → 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,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)
|
|
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
|
|
File without changes
|
{instance_repo-1.1.0 → instance_repo-1.1.0.dev0}/instance_repo.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|