msdev 0.9.0__py3-none-any.whl
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.
- msdev/__init__.py +3 -0
- msdev/cli.py +1460 -0
- msdev/core/__init__.py +1 -0
- msdev/core/config.py +193 -0
- msdev/core/guides.py +100 -0
- msdev/core/inventory.py +1390 -0
- msdev/core/limits.py +44 -0
- msdev/core/resources.py +300 -0
- msdev/core/services/__init__.py +111 -0
- msdev/core/services/context.py +61 -0
- msdev/core/services/environment.py +84 -0
- msdev/core/services/execution.py +136 -0
- msdev/core/services/model.py +1085 -0
- msdev/core/services/node.py +210 -0
- msdev/core/services/npu.py +116 -0
- msdev/core/services/workspace.py +567 -0
- msdev/core/transport.py +1225 -0
- msdev/core/workspace/__init__.py +36 -0
- msdev/core/workspace/access.py +1216 -0
- msdev/core/workspace/client.py +274 -0
- msdev/core/workspace/paths.py +45 -0
- msdev/core/workspace/registry.py +151 -0
- msdev/daemon.py +1322 -0
- msdev/session/__init__.py +13 -0
- msdev/session/export.py +190 -0
- msdev/session/log.py +269 -0
- msdev-0.9.0.dist-info/METADATA +295 -0
- msdev-0.9.0.dist-info/RECORD +31 -0
- msdev-0.9.0.dist-info/WHEEL +5 -0
- msdev-0.9.0.dist-info/entry_points.txt +3 -0
- msdev-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1085 @@
|
|
|
1
|
+
"""Typed model inventory application service for the msdev CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..inventory import SCHEMA_VERSION
|
|
12
|
+
from ..resources import Node
|
|
13
|
+
from ..limits import (
|
|
14
|
+
MAX_INVENTORY_ALIASES_PER_ARTIFACT,
|
|
15
|
+
MAX_INVENTORY_ARTIFACTS,
|
|
16
|
+
MAX_INVENTORY_CONTENT_BYTES,
|
|
17
|
+
MAX_INVENTORY_LINE_BYTES,
|
|
18
|
+
MAX_INVENTORY_REPLICAS_PER_ARTIFACT,
|
|
19
|
+
MAX_INVENTORY_TAGS_PER_ARTIFACT,
|
|
20
|
+
MAX_INVENTORY_TOTAL_ALIASES,
|
|
21
|
+
MAX_INVENTORY_TOTAL_REPLICAS,
|
|
22
|
+
MAX_INVENTORY_TOTAL_TAGS,
|
|
23
|
+
MAX_ROOT_MAP_PATH_BYTES,
|
|
24
|
+
MAX_ROOT_MAPS,
|
|
25
|
+
)
|
|
26
|
+
from ..transport import RpcError, RpcTransport
|
|
27
|
+
from .context import ServiceContext
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_MODEL_STATES = frozenset({"ready", "dirty", "missing", "broken"})
|
|
31
|
+
_MODEL_SCOPES = frozenset({"user", "team", "system"})
|
|
32
|
+
_MODEL_MANAGEMENT = frozenset({"external"})
|
|
33
|
+
_VERIFICATION_LEVELS = frozenset(
|
|
34
|
+
{"metadata_verified", "checksum_verified"}
|
|
35
|
+
)
|
|
36
|
+
_NAMESPACE_PATTERN = re.compile(r"[A-Za-z0-9._-]+")
|
|
37
|
+
|
|
38
|
+
_INVENTORY_KEYS = frozenset(
|
|
39
|
+
{"schema_version", "exported_at", "source_node", "artifacts"}
|
|
40
|
+
)
|
|
41
|
+
_JSONL_META_KEYS = frozenset(
|
|
42
|
+
{"type", "schema_version", "exported_at", "source_node"}
|
|
43
|
+
)
|
|
44
|
+
_JSONL_ARTIFACT_RECORD_KEYS = frozenset({"type", "value"})
|
|
45
|
+
_ARTIFACT_KEYS = frozenset(
|
|
46
|
+
{
|
|
47
|
+
"ref",
|
|
48
|
+
"revision",
|
|
49
|
+
"variant",
|
|
50
|
+
"format",
|
|
51
|
+
"manifest_digest",
|
|
52
|
+
"metadata",
|
|
53
|
+
"created_at",
|
|
54
|
+
"updated_at",
|
|
55
|
+
"description",
|
|
56
|
+
"deleted_at",
|
|
57
|
+
"aliases",
|
|
58
|
+
"tags",
|
|
59
|
+
"replicas",
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
_REPLICA_KEYS = frozenset(
|
|
63
|
+
{
|
|
64
|
+
"node_id",
|
|
65
|
+
"path",
|
|
66
|
+
"scope",
|
|
67
|
+
"management",
|
|
68
|
+
"state",
|
|
69
|
+
"verification_level",
|
|
70
|
+
"size_bytes",
|
|
71
|
+
"file_count",
|
|
72
|
+
"last_seen",
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _required_string(value: Any, name: str) -> str:
|
|
78
|
+
if not isinstance(value, str) or not value.strip():
|
|
79
|
+
raise ValueError(f"{name} must be a non-empty string")
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _optional_string(value: Any, name: str) -> str | None:
|
|
84
|
+
if value is None:
|
|
85
|
+
return None
|
|
86
|
+
if not isinstance(value, str):
|
|
87
|
+
raise ValueError(f"{name} must be a string")
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _boolean(value: Any, name: str) -> bool:
|
|
92
|
+
if type(value) is not bool:
|
|
93
|
+
raise ValueError(f"{name} must be a boolean")
|
|
94
|
+
return value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _bounded_integer(
|
|
98
|
+
value: Any,
|
|
99
|
+
name: str,
|
|
100
|
+
*,
|
|
101
|
+
minimum: int,
|
|
102
|
+
maximum: int,
|
|
103
|
+
) -> int:
|
|
104
|
+
if type(value) is not int:
|
|
105
|
+
raise ValueError(f"{name} must be an integer")
|
|
106
|
+
if not minimum <= value <= maximum:
|
|
107
|
+
raise ValueError(f"{name} must be between {minimum} and {maximum}")
|
|
108
|
+
return value
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _string_array(value: Any, name: str) -> tuple[str, ...]:
|
|
112
|
+
if not isinstance(value, (list, tuple)) or isinstance(value, (str, bytes)):
|
|
113
|
+
raise ValueError(f"{name} must be a string array")
|
|
114
|
+
return tuple(_required_string(item, f"{name} item") for item in value)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _nullable_string(value: Any, name: str) -> str | None:
|
|
118
|
+
if value is not None and not isinstance(value, str):
|
|
119
|
+
raise ValueError(f"{name} must be a string or null")
|
|
120
|
+
return value
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _nonnegative_integer(value: Any, name: str) -> int:
|
|
124
|
+
if type(value) is not int:
|
|
125
|
+
raise ValueError(f"{name} must be an integer")
|
|
126
|
+
if value < 0:
|
|
127
|
+
raise ValueError(f"{name} must be non-negative")
|
|
128
|
+
return value
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _validate_json_value(
|
|
132
|
+
value: Any,
|
|
133
|
+
name: str,
|
|
134
|
+
active_containers: set[int] | None = None,
|
|
135
|
+
) -> None:
|
|
136
|
+
active = active_containers if active_containers is not None else set()
|
|
137
|
+
if value is None or isinstance(value, (str, bool, int)):
|
|
138
|
+
return
|
|
139
|
+
if isinstance(value, float):
|
|
140
|
+
if not math.isfinite(value):
|
|
141
|
+
raise ValueError(f"{name} must contain only finite numbers")
|
|
142
|
+
return
|
|
143
|
+
if isinstance(value, list):
|
|
144
|
+
identity = id(value)
|
|
145
|
+
if identity in active:
|
|
146
|
+
raise ValueError(f"{name} contains a recursive JSON value")
|
|
147
|
+
active.add(identity)
|
|
148
|
+
try:
|
|
149
|
+
for index, item in enumerate(value):
|
|
150
|
+
_validate_json_value(item, f"{name}[{index}]", active)
|
|
151
|
+
finally:
|
|
152
|
+
active.remove(identity)
|
|
153
|
+
return
|
|
154
|
+
if isinstance(value, dict):
|
|
155
|
+
identity = id(value)
|
|
156
|
+
if identity in active:
|
|
157
|
+
raise ValueError(f"{name} contains a recursive JSON value")
|
|
158
|
+
active.add(identity)
|
|
159
|
+
try:
|
|
160
|
+
for key, item in value.items():
|
|
161
|
+
if not isinstance(key, str):
|
|
162
|
+
raise ValueError(f"{name} object keys must be strings")
|
|
163
|
+
_validate_json_value(item, f"{name}.{key}", active)
|
|
164
|
+
finally:
|
|
165
|
+
active.remove(identity)
|
|
166
|
+
return
|
|
167
|
+
raise ValueError(
|
|
168
|
+
f"{name} contains unsupported JSON value type: "
|
|
169
|
+
f"{type(value).__name__}"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _ensure_json_value(value: Any, name: str) -> None:
|
|
174
|
+
try:
|
|
175
|
+
_validate_json_value(value, name)
|
|
176
|
+
except RecursionError as exc:
|
|
177
|
+
raise ValueError(f"{name} is too deeply nested") from exc
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _reject_unknown_keys(
|
|
181
|
+
value: dict[str, Any],
|
|
182
|
+
allowed: frozenset[str],
|
|
183
|
+
name: str,
|
|
184
|
+
) -> None:
|
|
185
|
+
unknown = set(value) - allowed
|
|
186
|
+
if unknown:
|
|
187
|
+
rendered = ", ".join(sorted(repr(item) for item in unknown))
|
|
188
|
+
raise ValueError(f"{name} has unknown field(s): {rendered}")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _json_text(value: Any, name: str) -> str:
|
|
192
|
+
try:
|
|
193
|
+
return json.dumps(
|
|
194
|
+
value,
|
|
195
|
+
ensure_ascii=False,
|
|
196
|
+
sort_keys=True,
|
|
197
|
+
allow_nan=False,
|
|
198
|
+
)
|
|
199
|
+
except (TypeError, ValueError, OverflowError, RecursionError) as exc:
|
|
200
|
+
raise ValueError(f"{name} is not valid JSON data: {exc}") from exc
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _reject_json_constant(value: str) -> None:
|
|
204
|
+
raise ValueError(f"non-finite JSON number is not allowed: {value}")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _load_json(value: str, name: str) -> Any:
|
|
208
|
+
try:
|
|
209
|
+
return json.loads(
|
|
210
|
+
value,
|
|
211
|
+
parse_constant=_reject_json_constant,
|
|
212
|
+
)
|
|
213
|
+
except (json.JSONDecodeError, ValueError, RecursionError) as exc:
|
|
214
|
+
raise ValueError(f"invalid {name} JSON: {exc}") from exc
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _utf8_size(value: str, name: str) -> int:
|
|
218
|
+
try:
|
|
219
|
+
return len(value.encode("utf-8"))
|
|
220
|
+
except UnicodeEncodeError as exc:
|
|
221
|
+
raise ValueError(f"{name} is not valid UTF-8: {exc}") from exc
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _check_size(value: str, name: str, maximum: int) -> None:
|
|
225
|
+
if _utf8_size(value, name) > maximum:
|
|
226
|
+
raise ValueError(f"{name} exceeds {maximum} UTF-8 bytes")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _root_maps(value: Any) -> tuple[tuple[str, str], ...]:
|
|
230
|
+
if not isinstance(value, (list, tuple)) or isinstance(value, (str, bytes)):
|
|
231
|
+
raise ValueError("root_maps must be an array of [old, new] pairs")
|
|
232
|
+
if len(value) > MAX_ROOT_MAPS:
|
|
233
|
+
raise ValueError(f"root_maps must contain at most {MAX_ROOT_MAPS} pairs")
|
|
234
|
+
result: list[tuple[str, str]] = []
|
|
235
|
+
for item in value:
|
|
236
|
+
if (
|
|
237
|
+
not isinstance(item, (list, tuple))
|
|
238
|
+
or isinstance(item, (str, bytes))
|
|
239
|
+
or len(item) != 2
|
|
240
|
+
):
|
|
241
|
+
raise ValueError("each root mapping must contain exactly [old, new]")
|
|
242
|
+
old = _required_string(item[0], "root mapping old")
|
|
243
|
+
new = _required_string(item[1], "root mapping new")
|
|
244
|
+
for path, name in ((old, "old"), (new, "new")):
|
|
245
|
+
if _utf8_size(path, f"root mapping {name}") > MAX_ROOT_MAP_PATH_BYTES:
|
|
246
|
+
raise ValueError(
|
|
247
|
+
f"root mapping {name} exceeds "
|
|
248
|
+
f"{MAX_ROOT_MAP_PATH_BYTES} UTF-8 bytes"
|
|
249
|
+
)
|
|
250
|
+
result.append((old, new))
|
|
251
|
+
return tuple(result)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _validate_artifact(artifact: Any) -> dict[str, Any]:
|
|
255
|
+
if not isinstance(artifact, dict):
|
|
256
|
+
raise ValueError("each inventory artifact must be an object")
|
|
257
|
+
_reject_unknown_keys(
|
|
258
|
+
artifact,
|
|
259
|
+
_ARTIFACT_KEYS,
|
|
260
|
+
"inventory artifact",
|
|
261
|
+
)
|
|
262
|
+
_required_string(artifact.get("ref"), "inventory artifact ref")
|
|
263
|
+
|
|
264
|
+
for name in (
|
|
265
|
+
"manifest_digest",
|
|
266
|
+
"format",
|
|
267
|
+
"revision",
|
|
268
|
+
"variant",
|
|
269
|
+
"description",
|
|
270
|
+
"deleted_at",
|
|
271
|
+
"created_at",
|
|
272
|
+
"updated_at",
|
|
273
|
+
):
|
|
274
|
+
if name in artifact:
|
|
275
|
+
_nullable_string(artifact[name], f"inventory artifact {name}")
|
|
276
|
+
|
|
277
|
+
if "metadata" in artifact and not isinstance(artifact["metadata"], dict):
|
|
278
|
+
raise ValueError("inventory artifact metadata must be an object")
|
|
279
|
+
|
|
280
|
+
aliases = artifact.get("aliases", [])
|
|
281
|
+
if not isinstance(aliases, list):
|
|
282
|
+
raise ValueError("inventory artifact aliases must be an array")
|
|
283
|
+
if len(aliases) > MAX_INVENTORY_ALIASES_PER_ARTIFACT:
|
|
284
|
+
raise ValueError("inventory artifact has too many aliases")
|
|
285
|
+
_string_array(aliases, "inventory artifact aliases")
|
|
286
|
+
|
|
287
|
+
tags = artifact.get("tags", [])
|
|
288
|
+
if not isinstance(tags, list):
|
|
289
|
+
raise ValueError("inventory artifact tags must be an array")
|
|
290
|
+
if len(tags) > MAX_INVENTORY_TAGS_PER_ARTIFACT:
|
|
291
|
+
raise ValueError("inventory artifact has too many tags")
|
|
292
|
+
_string_array(tags, "inventory artifact tags")
|
|
293
|
+
|
|
294
|
+
replicas = artifact.get("replicas", [])
|
|
295
|
+
if not isinstance(replicas, list):
|
|
296
|
+
raise ValueError("inventory artifact replicas must be an array")
|
|
297
|
+
if len(replicas) > MAX_INVENTORY_REPLICAS_PER_ARTIFACT:
|
|
298
|
+
raise ValueError("inventory artifact has too many replicas")
|
|
299
|
+
for replica in replicas:
|
|
300
|
+
if not isinstance(replica, dict):
|
|
301
|
+
raise ValueError("each inventory replica must be an object")
|
|
302
|
+
_reject_unknown_keys(
|
|
303
|
+
replica,
|
|
304
|
+
_REPLICA_KEYS,
|
|
305
|
+
"inventory replica",
|
|
306
|
+
)
|
|
307
|
+
_required_string(replica.get("path"), "inventory replica path")
|
|
308
|
+
if "node_id" in replica:
|
|
309
|
+
_required_string(replica["node_id"], "inventory replica node_id")
|
|
310
|
+
if "scope" in replica:
|
|
311
|
+
scope = _required_string(replica["scope"], "inventory replica scope")
|
|
312
|
+
if scope not in _MODEL_SCOPES:
|
|
313
|
+
raise ValueError(f"unsupported inventory replica scope: {scope}")
|
|
314
|
+
if "management" in replica:
|
|
315
|
+
management = _required_string(
|
|
316
|
+
replica["management"],
|
|
317
|
+
"inventory replica management",
|
|
318
|
+
)
|
|
319
|
+
if management not in _MODEL_MANAGEMENT:
|
|
320
|
+
raise ValueError(
|
|
321
|
+
f"unsupported inventory replica management: {management}"
|
|
322
|
+
)
|
|
323
|
+
if "state" in replica:
|
|
324
|
+
state = _required_string(replica["state"], "inventory replica state")
|
|
325
|
+
if state not in _MODEL_STATES:
|
|
326
|
+
raise ValueError(f"unsupported inventory replica state: {state}")
|
|
327
|
+
if "verification_level" in replica:
|
|
328
|
+
level = _required_string(
|
|
329
|
+
replica["verification_level"],
|
|
330
|
+
"inventory replica verification_level",
|
|
331
|
+
)
|
|
332
|
+
if level not in _VERIFICATION_LEVELS:
|
|
333
|
+
raise ValueError(
|
|
334
|
+
f"unsupported inventory replica verification_level: {level}"
|
|
335
|
+
)
|
|
336
|
+
for name in ("size_bytes", "file_count"):
|
|
337
|
+
if name in replica:
|
|
338
|
+
_nonnegative_integer(
|
|
339
|
+
replica[name],
|
|
340
|
+
f"inventory replica {name}",
|
|
341
|
+
)
|
|
342
|
+
if "last_seen" in replica:
|
|
343
|
+
_required_string(
|
|
344
|
+
replica["last_seen"],
|
|
345
|
+
"inventory replica last_seen",
|
|
346
|
+
)
|
|
347
|
+
return artifact
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _add_artifact_fanout(
|
|
351
|
+
artifact: dict[str, Any],
|
|
352
|
+
totals: tuple[int, int, int],
|
|
353
|
+
) -> tuple[int, int, int]:
|
|
354
|
+
total_aliases = totals[0] + len(artifact.get("aliases", []))
|
|
355
|
+
total_tags = totals[1] + len(artifact.get("tags", []))
|
|
356
|
+
total_replicas = totals[2] + len(artifact.get("replicas", []))
|
|
357
|
+
if total_aliases > MAX_INVENTORY_TOTAL_ALIASES:
|
|
358
|
+
raise ValueError("inventory has too many total aliases")
|
|
359
|
+
if total_tags > MAX_INVENTORY_TOTAL_TAGS:
|
|
360
|
+
raise ValueError("inventory has too many total tags")
|
|
361
|
+
if total_replicas > MAX_INVENTORY_TOTAL_REPLICAS:
|
|
362
|
+
raise ValueError("inventory has too many total replicas")
|
|
363
|
+
return total_aliases, total_tags, total_replicas
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _validate_inventory_payload(
|
|
367
|
+
payload: Any,
|
|
368
|
+
*,
|
|
369
|
+
jsonl_lines: list[str] | None = None,
|
|
370
|
+
) -> dict[str, Any]:
|
|
371
|
+
if not isinstance(payload, dict):
|
|
372
|
+
raise ValueError("inventory payload must be an object")
|
|
373
|
+
_ensure_json_value(payload, "inventory payload")
|
|
374
|
+
_reject_unknown_keys(
|
|
375
|
+
payload,
|
|
376
|
+
_INVENTORY_KEYS,
|
|
377
|
+
"inventory payload",
|
|
378
|
+
)
|
|
379
|
+
schema_version = payload.get("schema_version")
|
|
380
|
+
if type(schema_version) is not int:
|
|
381
|
+
raise ValueError("inventory schema_version must be an integer")
|
|
382
|
+
if schema_version != SCHEMA_VERSION:
|
|
383
|
+
raise ValueError(f"unsupported inventory schema: {schema_version}")
|
|
384
|
+
for name in ("exported_at", "source_node"):
|
|
385
|
+
if name in payload:
|
|
386
|
+
_nullable_string(payload[name], f"inventory {name}")
|
|
387
|
+
artifacts = payload.get("artifacts")
|
|
388
|
+
if not isinstance(artifacts, list):
|
|
389
|
+
raise ValueError("inventory artifacts must be an array")
|
|
390
|
+
if len(artifacts) > MAX_INVENTORY_ARTIFACTS:
|
|
391
|
+
raise ValueError("inventory contains too many artifacts")
|
|
392
|
+
|
|
393
|
+
header = _json_text(
|
|
394
|
+
{
|
|
395
|
+
"type": "meta",
|
|
396
|
+
"schema_version": schema_version,
|
|
397
|
+
"exported_at": payload.get("exported_at"),
|
|
398
|
+
"source_node": payload.get("source_node"),
|
|
399
|
+
},
|
|
400
|
+
"inventory metadata",
|
|
401
|
+
)
|
|
402
|
+
_check_size(header, "inventory metadata line", MAX_INVENTORY_LINE_BYTES)
|
|
403
|
+
if jsonl_lines is not None:
|
|
404
|
+
jsonl_lines.append(header)
|
|
405
|
+
serialized_bytes = _utf8_size(header, "inventory metadata line") + 1
|
|
406
|
+
if serialized_bytes > MAX_INVENTORY_CONTENT_BYTES:
|
|
407
|
+
raise ValueError(
|
|
408
|
+
"inventory JSONL content exceeds "
|
|
409
|
+
f"{MAX_INVENTORY_CONTENT_BYTES} UTF-8 bytes"
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
fanout = (0, 0, 0)
|
|
413
|
+
for artifact in artifacts:
|
|
414
|
+
_validate_artifact(artifact)
|
|
415
|
+
fanout = _add_artifact_fanout(artifact, fanout)
|
|
416
|
+
artifact_line = _json_text(
|
|
417
|
+
{"type": "artifact", "value": artifact},
|
|
418
|
+
"inventory artifact",
|
|
419
|
+
)
|
|
420
|
+
_check_size(
|
|
421
|
+
artifact_line,
|
|
422
|
+
"inventory artifact line",
|
|
423
|
+
MAX_INVENTORY_LINE_BYTES,
|
|
424
|
+
)
|
|
425
|
+
if jsonl_lines is not None:
|
|
426
|
+
jsonl_lines.append(artifact_line)
|
|
427
|
+
serialized_bytes += _utf8_size(
|
|
428
|
+
artifact_line,
|
|
429
|
+
"inventory artifact line",
|
|
430
|
+
) + 1
|
|
431
|
+
if serialized_bytes > MAX_INVENTORY_CONTENT_BYTES:
|
|
432
|
+
raise ValueError(
|
|
433
|
+
"inventory JSONL content exceeds "
|
|
434
|
+
f"{MAX_INVENTORY_CONTENT_BYTES} UTF-8 bytes"
|
|
435
|
+
)
|
|
436
|
+
return payload
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _model_list_rows(value: Any) -> tuple[dict[str, Any], ...]:
|
|
440
|
+
if not isinstance(value, list):
|
|
441
|
+
raise ValueError("model.list response must be an array")
|
|
442
|
+
if not all(isinstance(item, dict) for item in value):
|
|
443
|
+
raise ValueError("every model.list response item must be an object")
|
|
444
|
+
return tuple(value)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def inventory_to_jsonl(payload: Any) -> str:
|
|
448
|
+
"""Serialize structured inventory records using the CLI-compatible JSONL format."""
|
|
449
|
+
lines: list[str] = []
|
|
450
|
+
_validate_inventory_payload(payload, jsonl_lines=lines)
|
|
451
|
+
return "\n".join(lines) + "\n"
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def inventory_from_jsonl(content: Any) -> dict[str, Any]:
|
|
455
|
+
"""Parse CLI-compatible JSONL into structured inventory records."""
|
|
456
|
+
if not isinstance(content, str):
|
|
457
|
+
raise ValueError("inventory jsonl must be a string")
|
|
458
|
+
_check_size(
|
|
459
|
+
content,
|
|
460
|
+
"inventory JSONL content",
|
|
461
|
+
MAX_INVENTORY_CONTENT_BYTES,
|
|
462
|
+
)
|
|
463
|
+
lines = content.splitlines()
|
|
464
|
+
if not lines:
|
|
465
|
+
raise ValueError("empty inventory export")
|
|
466
|
+
for line in lines:
|
|
467
|
+
_check_size(
|
|
468
|
+
line,
|
|
469
|
+
"inventory JSONL line",
|
|
470
|
+
MAX_INVENTORY_LINE_BYTES,
|
|
471
|
+
)
|
|
472
|
+
meta = _load_json(lines[0], "inventory metadata")
|
|
473
|
+
if not isinstance(meta, dict) or meta.get("type") != "meta":
|
|
474
|
+
raise ValueError("inventory export is missing metadata header")
|
|
475
|
+
_ensure_json_value(meta, "inventory metadata")
|
|
476
|
+
_reject_unknown_keys(
|
|
477
|
+
meta,
|
|
478
|
+
_JSONL_META_KEYS,
|
|
479
|
+
"inventory metadata",
|
|
480
|
+
)
|
|
481
|
+
artifacts: list[dict[str, Any]] = []
|
|
482
|
+
fanout = (0, 0, 0)
|
|
483
|
+
for line in lines[1:]:
|
|
484
|
+
if not line.strip():
|
|
485
|
+
continue
|
|
486
|
+
record = _load_json(line, "inventory record")
|
|
487
|
+
if not isinstance(record, dict):
|
|
488
|
+
raise ValueError("inventory record must be an object")
|
|
489
|
+
_ensure_json_value(record, "inventory record")
|
|
490
|
+
_reject_unknown_keys(
|
|
491
|
+
record,
|
|
492
|
+
_JSONL_ARTIFACT_RECORD_KEYS,
|
|
493
|
+
"inventory artifact record",
|
|
494
|
+
)
|
|
495
|
+
record_type = record.get("type")
|
|
496
|
+
if record_type != "artifact":
|
|
497
|
+
raise ValueError(
|
|
498
|
+
f"unsupported inventory JSONL record type: {record_type!r}"
|
|
499
|
+
)
|
|
500
|
+
if len(artifacts) >= MAX_INVENTORY_ARTIFACTS:
|
|
501
|
+
raise ValueError("inventory contains too many artifacts")
|
|
502
|
+
artifact = _validate_artifact(record.get("value"))
|
|
503
|
+
fanout = _add_artifact_fanout(artifact, fanout)
|
|
504
|
+
artifacts.append(artifact)
|
|
505
|
+
payload = {
|
|
506
|
+
"schema_version": meta.get("schema_version"),
|
|
507
|
+
"exported_at": meta.get("exported_at"),
|
|
508
|
+
"source_node": meta.get("source_node"),
|
|
509
|
+
"artifacts": artifacts,
|
|
510
|
+
}
|
|
511
|
+
return _validate_inventory_payload(payload)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
@dataclass(frozen=True)
|
|
515
|
+
class ModelRefRequest:
|
|
516
|
+
node: Any
|
|
517
|
+
ref: Any
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
@dataclass(frozen=True)
|
|
521
|
+
class ModelRegisterRequest:
|
|
522
|
+
node: Any
|
|
523
|
+
ref: Any
|
|
524
|
+
path: Any
|
|
525
|
+
scope: Any = "user"
|
|
526
|
+
format_name: Any = None
|
|
527
|
+
full_checksum: Any = False
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
@dataclass(frozen=True)
|
|
531
|
+
class ModelDiscoverRequest:
|
|
532
|
+
node: Any
|
|
533
|
+
root: Any
|
|
534
|
+
max_depth: Any = 5
|
|
535
|
+
register: Any = False
|
|
536
|
+
namespace: Any = "discovered"
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
@dataclass(frozen=True)
|
|
540
|
+
class ModelListRequest:
|
|
541
|
+
node: Any = None
|
|
542
|
+
all_nodes: Any = False
|
|
543
|
+
format_name: Any = None
|
|
544
|
+
model_type: Any = None
|
|
545
|
+
state: Any = None
|
|
546
|
+
tag: Any = None
|
|
547
|
+
name: Any = None
|
|
548
|
+
include_deleted: Any = False
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
@dataclass(frozen=True)
|
|
552
|
+
class ModelUpdateRequest:
|
|
553
|
+
node: Any
|
|
554
|
+
ref: Any
|
|
555
|
+
add_aliases: Any = ()
|
|
556
|
+
remove_aliases: Any = ()
|
|
557
|
+
add_tags: Any = ()
|
|
558
|
+
remove_tags: Any = ()
|
|
559
|
+
description: Any = None
|
|
560
|
+
clear_description: Any = False
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
@dataclass(frozen=True)
|
|
564
|
+
class ModelRefreshRequest:
|
|
565
|
+
node: Any
|
|
566
|
+
ref: Any
|
|
567
|
+
path: Any = None
|
|
568
|
+
full_checksum: Any = False
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
@dataclass(frozen=True)
|
|
572
|
+
class ModelValidateRequest:
|
|
573
|
+
node: Any
|
|
574
|
+
ref: Any
|
|
575
|
+
path: Any = None
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
@dataclass(frozen=True)
|
|
579
|
+
class ModelAuditRequest:
|
|
580
|
+
node: Any
|
|
581
|
+
ref: Any = None
|
|
582
|
+
limit: Any = 100
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
@dataclass(frozen=True)
|
|
586
|
+
class ModelReplicaAddRequest:
|
|
587
|
+
node: Any
|
|
588
|
+
ref: Any
|
|
589
|
+
path: Any
|
|
590
|
+
full_checksum: Any = False
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
@dataclass(frozen=True)
|
|
594
|
+
class ModelReplicaRemoveRequest:
|
|
595
|
+
node: Any
|
|
596
|
+
ref: Any
|
|
597
|
+
path: Any
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
@dataclass(frozen=True)
|
|
601
|
+
class ModelVerifyRequest:
|
|
602
|
+
node: Any
|
|
603
|
+
ref: Any
|
|
604
|
+
full_checksum: Any = False
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
@dataclass(frozen=True)
|
|
608
|
+
class ModelUnregisterRequest:
|
|
609
|
+
node: Any
|
|
610
|
+
ref: Any
|
|
611
|
+
path: Any = None
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
@dataclass(frozen=True)
|
|
615
|
+
class ModelImportRequest:
|
|
616
|
+
node: Any
|
|
617
|
+
payload: Any = None
|
|
618
|
+
jsonl: Any = None
|
|
619
|
+
root_maps: Any = ()
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
@dataclass(frozen=True)
|
|
623
|
+
class ModelRebindRequest:
|
|
624
|
+
node: Any
|
|
625
|
+
ref: Any
|
|
626
|
+
path: Any
|
|
627
|
+
scope: Any = "user"
|
|
628
|
+
full_checksum: Any = False
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
@dataclass(frozen=True)
|
|
632
|
+
class ModelOperationResult:
|
|
633
|
+
node: str
|
|
634
|
+
value: Any
|
|
635
|
+
exit_code: int = 0
|
|
636
|
+
|
|
637
|
+
def to_dict(self) -> dict[str, Any]:
|
|
638
|
+
return {
|
|
639
|
+
"node": self.node,
|
|
640
|
+
"value": self.value,
|
|
641
|
+
"exit_code": self.exit_code,
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
@dataclass(frozen=True)
|
|
646
|
+
class ModelNodeResult:
|
|
647
|
+
node: str
|
|
648
|
+
ssh_host: str | None
|
|
649
|
+
models: tuple[dict[str, Any], ...]
|
|
650
|
+
|
|
651
|
+
def to_dict(self) -> dict[str, Any]:
|
|
652
|
+
return {
|
|
653
|
+
"node": self.node,
|
|
654
|
+
"ssh_host": self.ssh_host,
|
|
655
|
+
"models": list(self.models),
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
@dataclass(frozen=True)
|
|
660
|
+
class ModelNodeError:
|
|
661
|
+
node: str
|
|
662
|
+
ssh_host: str
|
|
663
|
+
error: str
|
|
664
|
+
|
|
665
|
+
def to_dict(self) -> dict[str, str]:
|
|
666
|
+
return {
|
|
667
|
+
"node": self.node,
|
|
668
|
+
"ssh_host": self.ssh_host,
|
|
669
|
+
"error": self.error,
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
@dataclass(frozen=True)
|
|
674
|
+
class ModelListResult:
|
|
675
|
+
nodes: tuple[ModelNodeResult, ...]
|
|
676
|
+
errors: tuple[ModelNodeError, ...] = ()
|
|
677
|
+
exit_code: int = 0
|
|
678
|
+
|
|
679
|
+
def to_dict(self) -> dict[str, Any]:
|
|
680
|
+
return {
|
|
681
|
+
"nodes": [item.to_dict() for item in self.nodes],
|
|
682
|
+
"errors": [item.to_dict() for item in self.errors],
|
|
683
|
+
"exit_code": self.exit_code,
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
@dataclass(frozen=True)
|
|
688
|
+
class ModelExportResult:
|
|
689
|
+
node: str
|
|
690
|
+
payload: dict[str, Any]
|
|
691
|
+
jsonl: str
|
|
692
|
+
exit_code: int = 0
|
|
693
|
+
|
|
694
|
+
def to_dict(self) -> dict[str, Any]:
|
|
695
|
+
return {
|
|
696
|
+
"node": self.node,
|
|
697
|
+
"payload": self.payload,
|
|
698
|
+
"jsonl": self.jsonl,
|
|
699
|
+
"exit_code": self.exit_code,
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
class ModelService:
|
|
704
|
+
"""Own validation, node routing, and model inventory orchestration."""
|
|
705
|
+
|
|
706
|
+
def __init__(self, context: ServiceContext):
|
|
707
|
+
self.context = context
|
|
708
|
+
|
|
709
|
+
def _transport(self, node_name: Any) -> tuple[str, Node | None, RpcTransport]:
|
|
710
|
+
name = _required_string(node_name, "node")
|
|
711
|
+
node = None if name == "local" else self.context.node_store.get(name)
|
|
712
|
+
return name, node, self.context.create_transport(node)
|
|
713
|
+
|
|
714
|
+
@staticmethod
|
|
715
|
+
def _ref(request: ModelRefRequest) -> str:
|
|
716
|
+
return _required_string(request.ref, "model ref")
|
|
717
|
+
|
|
718
|
+
def _call(
|
|
719
|
+
self,
|
|
720
|
+
node: Any,
|
|
721
|
+
method: str,
|
|
722
|
+
params: dict[str, Any],
|
|
723
|
+
*,
|
|
724
|
+
exit_code: int = 0,
|
|
725
|
+
) -> ModelOperationResult:
|
|
726
|
+
name, _node, transport = self._transport(node)
|
|
727
|
+
value = transport.call(method, params)
|
|
728
|
+
return ModelOperationResult(name, value, exit_code)
|
|
729
|
+
|
|
730
|
+
def register(self, request: ModelRegisterRequest) -> ModelOperationResult:
|
|
731
|
+
ref = _required_string(request.ref, "model ref")
|
|
732
|
+
path = _required_string(request.path, "model path")
|
|
733
|
+
scope = _required_string(request.scope, "scope")
|
|
734
|
+
if scope not in _MODEL_SCOPES:
|
|
735
|
+
raise ValueError(f"unsupported scope: {scope}")
|
|
736
|
+
format_name = _optional_string(request.format_name, "format")
|
|
737
|
+
full_checksum = _boolean(request.full_checksum, "full_checksum")
|
|
738
|
+
return self._call(
|
|
739
|
+
request.node,
|
|
740
|
+
"model.register",
|
|
741
|
+
{
|
|
742
|
+
"ref": ref,
|
|
743
|
+
"path": path,
|
|
744
|
+
"scope": scope,
|
|
745
|
+
"format": format_name,
|
|
746
|
+
"full_checksum": full_checksum,
|
|
747
|
+
},
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
def discover(self, request: ModelDiscoverRequest) -> ModelOperationResult:
|
|
751
|
+
root = _required_string(request.root, "discovery root")
|
|
752
|
+
max_depth = _bounded_integer(
|
|
753
|
+
request.max_depth,
|
|
754
|
+
"max_depth",
|
|
755
|
+
minimum=0,
|
|
756
|
+
maximum=20,
|
|
757
|
+
)
|
|
758
|
+
register = _boolean(request.register, "register")
|
|
759
|
+
namespace = _required_string(request.namespace, "namespace")
|
|
760
|
+
if _NAMESPACE_PATTERN.fullmatch(namespace) is None:
|
|
761
|
+
raise ValueError(
|
|
762
|
+
"namespace may only contain letters, numbers, dot, underscore, and dash"
|
|
763
|
+
)
|
|
764
|
+
return self._call(
|
|
765
|
+
request.node,
|
|
766
|
+
"model.discover",
|
|
767
|
+
{
|
|
768
|
+
"root": root,
|
|
769
|
+
"max_depth": max_depth,
|
|
770
|
+
"register": register,
|
|
771
|
+
"namespace": namespace,
|
|
772
|
+
},
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
@staticmethod
|
|
776
|
+
def _list_params(request: ModelListRequest) -> dict[str, Any]:
|
|
777
|
+
state = _optional_string(request.state, "state")
|
|
778
|
+
if state is not None and state not in _MODEL_STATES:
|
|
779
|
+
raise ValueError(f"unsupported model state: {state}")
|
|
780
|
+
return {
|
|
781
|
+
"format": _optional_string(request.format_name, "format"),
|
|
782
|
+
"model_type": _optional_string(request.model_type, "model_type"),
|
|
783
|
+
"state": state,
|
|
784
|
+
"tag": _optional_string(request.tag, "tag"),
|
|
785
|
+
"name": _optional_string(request.name, "name"),
|
|
786
|
+
"include_deleted": _boolean(
|
|
787
|
+
request.include_deleted,
|
|
788
|
+
"include_deleted",
|
|
789
|
+
),
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
def list(self, request: ModelListRequest) -> ModelListResult:
|
|
793
|
+
all_nodes = _boolean(request.all_nodes, "all_nodes")
|
|
794
|
+
if all_nodes:
|
|
795
|
+
if request.node is not None:
|
|
796
|
+
raise ValueError("node and all_nodes cannot be used together")
|
|
797
|
+
elif request.node is None:
|
|
798
|
+
raise ValueError("node or all_nodes is required")
|
|
799
|
+
params = self._list_params(request)
|
|
800
|
+
|
|
801
|
+
if not all_nodes:
|
|
802
|
+
name, node, transport = self._transport(request.node)
|
|
803
|
+
models = _model_list_rows(transport.call("model.list", params))
|
|
804
|
+
return ModelListResult(
|
|
805
|
+
nodes=(
|
|
806
|
+
ModelNodeResult(
|
|
807
|
+
node=name,
|
|
808
|
+
ssh_host=node.ssh_host if node is not None else None,
|
|
809
|
+
models=models,
|
|
810
|
+
),
|
|
811
|
+
)
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
configured_nodes = self.context.node_store.list()
|
|
815
|
+
if not configured_nodes:
|
|
816
|
+
raise ValueError("no SSH nodes registered")
|
|
817
|
+
nodes: list[ModelNodeResult] = []
|
|
818
|
+
errors: list[ModelNodeError] = []
|
|
819
|
+
for node in configured_nodes:
|
|
820
|
+
try:
|
|
821
|
+
transport = self.context.create_transport(node)
|
|
822
|
+
models = _model_list_rows(
|
|
823
|
+
transport.call("model.list", params)
|
|
824
|
+
)
|
|
825
|
+
nodes.append(
|
|
826
|
+
ModelNodeResult(
|
|
827
|
+
node=node.name,
|
|
828
|
+
ssh_host=node.ssh_host,
|
|
829
|
+
models=models,
|
|
830
|
+
)
|
|
831
|
+
)
|
|
832
|
+
except (
|
|
833
|
+
RpcError,
|
|
834
|
+
OSError,
|
|
835
|
+
ValueError,
|
|
836
|
+
TypeError,
|
|
837
|
+
RuntimeError,
|
|
838
|
+
) as exc:
|
|
839
|
+
errors.append(
|
|
840
|
+
ModelNodeError(
|
|
841
|
+
node=node.name,
|
|
842
|
+
ssh_host=node.ssh_host,
|
|
843
|
+
error=str(exc),
|
|
844
|
+
)
|
|
845
|
+
)
|
|
846
|
+
return ModelListResult(
|
|
847
|
+
nodes=tuple(nodes),
|
|
848
|
+
errors=tuple(errors),
|
|
849
|
+
exit_code=1 if errors else 0,
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
def inspect(self, request: ModelRefRequest) -> ModelOperationResult:
|
|
853
|
+
return self._call(
|
|
854
|
+
request.node,
|
|
855
|
+
"model.inspect",
|
|
856
|
+
{"ref": self._ref(request)},
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
def update(self, request: ModelUpdateRequest) -> ModelOperationResult:
|
|
860
|
+
description = _optional_string(request.description, "description")
|
|
861
|
+
clear_description = _boolean(
|
|
862
|
+
request.clear_description,
|
|
863
|
+
"clear_description",
|
|
864
|
+
)
|
|
865
|
+
if description is not None and clear_description:
|
|
866
|
+
raise ValueError("description and clear_description cannot be used together")
|
|
867
|
+
return self._call(
|
|
868
|
+
request.node,
|
|
869
|
+
"model.update",
|
|
870
|
+
{
|
|
871
|
+
"ref": _required_string(request.ref, "model ref"),
|
|
872
|
+
"add_aliases": list(
|
|
873
|
+
_string_array(request.add_aliases, "add_aliases")
|
|
874
|
+
),
|
|
875
|
+
"remove_aliases": list(
|
|
876
|
+
_string_array(request.remove_aliases, "remove_aliases")
|
|
877
|
+
),
|
|
878
|
+
"add_tags": list(_string_array(request.add_tags, "add_tags")),
|
|
879
|
+
"remove_tags": list(
|
|
880
|
+
_string_array(request.remove_tags, "remove_tags")
|
|
881
|
+
),
|
|
882
|
+
"description": description,
|
|
883
|
+
"clear_description": clear_description,
|
|
884
|
+
},
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
def delete(self, request: ModelRefRequest) -> ModelOperationResult:
|
|
888
|
+
return self._call(
|
|
889
|
+
request.node,
|
|
890
|
+
"model.delete",
|
|
891
|
+
{"ref": self._ref(request)},
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
def restore(self, request: ModelRefRequest) -> ModelOperationResult:
|
|
895
|
+
return self._call(
|
|
896
|
+
request.node,
|
|
897
|
+
"model.restore",
|
|
898
|
+
{"ref": self._ref(request)},
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
def refresh(self, request: ModelRefreshRequest) -> ModelOperationResult:
|
|
902
|
+
return self._call(
|
|
903
|
+
request.node,
|
|
904
|
+
"model.refresh",
|
|
905
|
+
{
|
|
906
|
+
"ref": _required_string(request.ref, "model ref"),
|
|
907
|
+
"path": _optional_string(request.path, "model path"),
|
|
908
|
+
"full_checksum": _boolean(
|
|
909
|
+
request.full_checksum,
|
|
910
|
+
"full_checksum",
|
|
911
|
+
),
|
|
912
|
+
},
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
def validate(
|
|
916
|
+
self,
|
|
917
|
+
request: ModelValidateRequest,
|
|
918
|
+
) -> ModelOperationResult:
|
|
919
|
+
result = self._call(
|
|
920
|
+
request.node,
|
|
921
|
+
"model.validate",
|
|
922
|
+
{
|
|
923
|
+
"ref": _required_string(request.ref, "model ref"),
|
|
924
|
+
"path": _optional_string(request.path, "model path"),
|
|
925
|
+
},
|
|
926
|
+
)
|
|
927
|
+
if not isinstance(result.value, dict) or type(result.value.get("valid")) is not bool:
|
|
928
|
+
raise ValueError("model.validate response must contain boolean valid")
|
|
929
|
+
return ModelOperationResult(
|
|
930
|
+
node=result.node,
|
|
931
|
+
value=result.value,
|
|
932
|
+
exit_code=0 if result.value["valid"] else 1,
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
def audit(self, request: ModelAuditRequest) -> ModelOperationResult:
|
|
936
|
+
ref = _optional_string(request.ref, "model ref")
|
|
937
|
+
limit = _bounded_integer(
|
|
938
|
+
request.limit,
|
|
939
|
+
"audit limit",
|
|
940
|
+
minimum=1,
|
|
941
|
+
maximum=1000,
|
|
942
|
+
)
|
|
943
|
+
return self._call(
|
|
944
|
+
request.node,
|
|
945
|
+
"model.audit",
|
|
946
|
+
{"ref": ref, "limit": limit},
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
def replica_list(self, request: ModelRefRequest) -> ModelOperationResult:
|
|
950
|
+
return self._call(
|
|
951
|
+
request.node,
|
|
952
|
+
"replica.list",
|
|
953
|
+
{"ref": self._ref(request)},
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
def replica_add(
|
|
957
|
+
self,
|
|
958
|
+
request: ModelReplicaAddRequest,
|
|
959
|
+
) -> ModelOperationResult:
|
|
960
|
+
return self._call(
|
|
961
|
+
request.node,
|
|
962
|
+
"replica.add",
|
|
963
|
+
{
|
|
964
|
+
"ref": _required_string(request.ref, "model ref"),
|
|
965
|
+
"path": _required_string(request.path, "replica path"),
|
|
966
|
+
"full_checksum": _boolean(
|
|
967
|
+
request.full_checksum,
|
|
968
|
+
"full_checksum",
|
|
969
|
+
),
|
|
970
|
+
},
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
def replica_remove(
|
|
974
|
+
self,
|
|
975
|
+
request: ModelReplicaRemoveRequest,
|
|
976
|
+
) -> ModelOperationResult:
|
|
977
|
+
return self._call(
|
|
978
|
+
request.node,
|
|
979
|
+
"replica.remove",
|
|
980
|
+
{
|
|
981
|
+
"ref": _required_string(request.ref, "model ref"),
|
|
982
|
+
"path": _required_string(request.path, "replica path"),
|
|
983
|
+
},
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
def verify(self, request: ModelVerifyRequest) -> ModelOperationResult:
|
|
987
|
+
result = self._call(
|
|
988
|
+
request.node,
|
|
989
|
+
"model.verify",
|
|
990
|
+
{
|
|
991
|
+
"ref": _required_string(request.ref, "model ref"),
|
|
992
|
+
"full_checksum": _boolean(
|
|
993
|
+
request.full_checksum,
|
|
994
|
+
"full_checksum",
|
|
995
|
+
),
|
|
996
|
+
},
|
|
997
|
+
)
|
|
998
|
+
if not isinstance(result.value, dict) or not isinstance(
|
|
999
|
+
result.value.get("results"),
|
|
1000
|
+
list,
|
|
1001
|
+
):
|
|
1002
|
+
raise ValueError("model.verify response must contain a results array")
|
|
1003
|
+
entries = result.value["results"]
|
|
1004
|
+
if not entries:
|
|
1005
|
+
raise ValueError("model.verify results must not be empty")
|
|
1006
|
+
states: list[str] = []
|
|
1007
|
+
for index, item in enumerate(entries):
|
|
1008
|
+
if not isinstance(item, dict):
|
|
1009
|
+
raise ValueError(
|
|
1010
|
+
f"model.verify result {index} must be an object"
|
|
1011
|
+
)
|
|
1012
|
+
state = item.get("state")
|
|
1013
|
+
if state not in _MODEL_STATES:
|
|
1014
|
+
raise ValueError(
|
|
1015
|
+
f"model.verify result {index} has invalid state: {state!r}"
|
|
1016
|
+
)
|
|
1017
|
+
states.append(state)
|
|
1018
|
+
return ModelOperationResult(
|
|
1019
|
+
node=result.node,
|
|
1020
|
+
value=result.value,
|
|
1021
|
+
exit_code=0 if all(state == "ready" for state in states) else 1,
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
def unregister(
|
|
1025
|
+
self,
|
|
1026
|
+
request: ModelUnregisterRequest,
|
|
1027
|
+
) -> ModelOperationResult:
|
|
1028
|
+
return self._call(
|
|
1029
|
+
request.node,
|
|
1030
|
+
"model.unregister",
|
|
1031
|
+
{
|
|
1032
|
+
"ref": _required_string(request.ref, "model ref"),
|
|
1033
|
+
"path": _optional_string(request.path, "replica path"),
|
|
1034
|
+
},
|
|
1035
|
+
)
|
|
1036
|
+
|
|
1037
|
+
def export(self, node: Any) -> ModelExportResult:
|
|
1038
|
+
name, _node, transport = self._transport(node)
|
|
1039
|
+
payload = _validate_inventory_payload(transport.call("model.export"))
|
|
1040
|
+
return ModelExportResult(
|
|
1041
|
+
node=name,
|
|
1042
|
+
payload=payload,
|
|
1043
|
+
jsonl=inventory_to_jsonl(payload),
|
|
1044
|
+
)
|
|
1045
|
+
|
|
1046
|
+
def import_records(
|
|
1047
|
+
self,
|
|
1048
|
+
request: ModelImportRequest,
|
|
1049
|
+
) -> ModelOperationResult:
|
|
1050
|
+
if request.payload is None and request.jsonl is None:
|
|
1051
|
+
raise ValueError("exactly one of payload or jsonl is required")
|
|
1052
|
+
if request.payload is not None and request.jsonl is not None:
|
|
1053
|
+
raise ValueError("payload and jsonl cannot be used together")
|
|
1054
|
+
payload = (
|
|
1055
|
+
_validate_inventory_payload(request.payload)
|
|
1056
|
+
if request.payload is not None
|
|
1057
|
+
else inventory_from_jsonl(request.jsonl)
|
|
1058
|
+
)
|
|
1059
|
+
maps = _root_maps(request.root_maps)
|
|
1060
|
+
return self._call(
|
|
1061
|
+
request.node,
|
|
1062
|
+
"model.import",
|
|
1063
|
+
{
|
|
1064
|
+
"payload": payload,
|
|
1065
|
+
"root_maps": [list(item) for item in maps],
|
|
1066
|
+
},
|
|
1067
|
+
)
|
|
1068
|
+
|
|
1069
|
+
def rebind(self, request: ModelRebindRequest) -> ModelOperationResult:
|
|
1070
|
+
scope = _required_string(request.scope, "scope")
|
|
1071
|
+
if scope not in _MODEL_SCOPES:
|
|
1072
|
+
raise ValueError(f"unsupported scope: {scope}")
|
|
1073
|
+
return self._call(
|
|
1074
|
+
request.node,
|
|
1075
|
+
"model.rebind",
|
|
1076
|
+
{
|
|
1077
|
+
"ref": _required_string(request.ref, "model ref"),
|
|
1078
|
+
"path": _required_string(request.path, "model path"),
|
|
1079
|
+
"scope": scope,
|
|
1080
|
+
"full_checksum": _boolean(
|
|
1081
|
+
request.full_checksum,
|
|
1082
|
+
"full_checksum",
|
|
1083
|
+
),
|
|
1084
|
+
},
|
|
1085
|
+
)
|