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
msdev/core/limits.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Shared practical ingress and model-import work limits."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
MAX_JSON_RPC_REQUEST_BYTES = 64 * 1024 * 1024
|
|
7
|
+
# A maximum 16 MiB workspace read expands to about 21.4 MiB in base64.
|
|
8
|
+
# 32 MiB preserves that response plus its JSON envelope with finite headroom.
|
|
9
|
+
MAX_RPC_RESPONSE_BYTES = 32 * 1024 * 1024
|
|
10
|
+
MAX_RPC_ERROR_BYTES = 1 * 1024 * 1024
|
|
11
|
+
|
|
12
|
+
DEFAULT_EXEC_TIMEOUT_SECONDS = 300.0
|
|
13
|
+
EXEC_RPC_COMPLETION_GRACE_SECONDS = 10.0
|
|
14
|
+
RPC_PROCESS_CLEANUP_GRACE_SECONDS = 2.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validate_exec_timeout_seconds(value: Any) -> float:
|
|
18
|
+
if (
|
|
19
|
+
isinstance(value, bool)
|
|
20
|
+
or not isinstance(value, (int, float))
|
|
21
|
+
or not math.isfinite(value)
|
|
22
|
+
or (value <= 0 and value != -1)
|
|
23
|
+
):
|
|
24
|
+
raise ValueError(
|
|
25
|
+
"timeout_seconds must be a positive finite number or -1"
|
|
26
|
+
)
|
|
27
|
+
return float(value)
|
|
28
|
+
|
|
29
|
+
# Budget for conservative six-byte JSON escaping:
|
|
30
|
+
# 8 MiB content * 6 + 64 maps * 2 paths * 4 KiB * 6 = 51 MiB.
|
|
31
|
+
# The remaining 13 MiB covers the JSON-RPC envelope and structural overhead.
|
|
32
|
+
MAX_INVENTORY_CONTENT_BYTES = 8 * 1024 * 1024
|
|
33
|
+
MAX_INVENTORY_LINE_BYTES = 8 * 1024 * 1024
|
|
34
|
+
|
|
35
|
+
MAX_INVENTORY_ARTIFACTS = 10_000
|
|
36
|
+
MAX_INVENTORY_ALIASES_PER_ARTIFACT = 1_000
|
|
37
|
+
MAX_INVENTORY_TAGS_PER_ARTIFACT = 1_000
|
|
38
|
+
MAX_INVENTORY_REPLICAS_PER_ARTIFACT = 1_000
|
|
39
|
+
MAX_INVENTORY_TOTAL_ALIASES = 10_000
|
|
40
|
+
MAX_INVENTORY_TOTAL_TAGS = 10_000
|
|
41
|
+
MAX_INVENTORY_TOTAL_REPLICAS = 10_000
|
|
42
|
+
|
|
43
|
+
MAX_ROOT_MAPS = 64
|
|
44
|
+
MAX_ROOT_MAP_PATH_BYTES = 4 * 1024
|
msdev/core/resources.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Local registry for connection nodes and execution environments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import asdict, dataclass, replace
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .config import _exclusive_file_lock, config_home, parse_environment_spec
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
RESOURCE_CONFIG_VERSION = 2
|
|
17
|
+
_VARIABLE_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _required(value: str, label: str) -> str:
|
|
21
|
+
if not isinstance(value, str) or not value.strip() or "\0" in value:
|
|
22
|
+
raise ValueError(f"{label} must be a non-empty string without NUL")
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_variable_assignment(value: str) -> tuple[str, str]:
|
|
27
|
+
if not isinstance(value, str) or "\0" in value:
|
|
28
|
+
raise ValueError("environment assignment must be a string without NUL")
|
|
29
|
+
name, separator, item = value.partition("=")
|
|
30
|
+
if not separator or not _VARIABLE_NAME.fullmatch(name):
|
|
31
|
+
raise ValueError(f"invalid environment variable name: {name!r}")
|
|
32
|
+
return name, item
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def normalize_variables(values: tuple[str, ...] | list[str]) -> tuple[str, ...]:
|
|
36
|
+
normalized: dict[str, str] = {}
|
|
37
|
+
for value in values:
|
|
38
|
+
name, item = parse_variable_assignment(value)
|
|
39
|
+
if name in normalized:
|
|
40
|
+
raise ValueError(f"duplicate environment variable: {name}")
|
|
41
|
+
normalized[name] = item
|
|
42
|
+
return tuple(f"{name}={normalized[name]}" for name in sorted(normalized))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Node:
|
|
47
|
+
name: str
|
|
48
|
+
ssh_host: str
|
|
49
|
+
remote_bin: str = "~/.local/bin/msdevd"
|
|
50
|
+
auto_bootstrap: bool = True
|
|
51
|
+
variables: tuple[str, ...] = ()
|
|
52
|
+
|
|
53
|
+
def __post_init__(self) -> None:
|
|
54
|
+
_required(self.name, "node name")
|
|
55
|
+
_required(self.ssh_host, "SSH host")
|
|
56
|
+
_required(self.remote_bin, "remote binary")
|
|
57
|
+
if type(self.auto_bootstrap) is not bool:
|
|
58
|
+
raise ValueError("auto_bootstrap must be a boolean")
|
|
59
|
+
object.__setattr__(
|
|
60
|
+
self,
|
|
61
|
+
"variables",
|
|
62
|
+
normalize_variables(tuple(self.variables)),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class Environment:
|
|
68
|
+
name: str
|
|
69
|
+
node: str
|
|
70
|
+
runtime: str = "host"
|
|
71
|
+
container: str | None = None
|
|
72
|
+
layers: tuple[str, ...] = ()
|
|
73
|
+
|
|
74
|
+
def __post_init__(self) -> None:
|
|
75
|
+
_required(self.name, "environment name")
|
|
76
|
+
_required(self.node, "environment node")
|
|
77
|
+
object.__setattr__(self, "layers", tuple(self.layers))
|
|
78
|
+
if self.runtime not in {"host", "docker"}:
|
|
79
|
+
raise ValueError(f"unsupported environment runtime: {self.runtime}")
|
|
80
|
+
if self.runtime == "docker":
|
|
81
|
+
_required(self.container or "", "docker container")
|
|
82
|
+
elif self.container is not None:
|
|
83
|
+
raise ValueError("host environment cannot define a container")
|
|
84
|
+
for layer in self.layers:
|
|
85
|
+
parse_environment_spec(layer)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _ResourceStore:
|
|
89
|
+
def __init__(self, path: Path | None = None):
|
|
90
|
+
self.path = path or config_home() / "resources.json"
|
|
91
|
+
self.lock_path = self.path.with_name(f".{self.path.name}.lock")
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _empty() -> dict[str, Any]:
|
|
95
|
+
return {
|
|
96
|
+
"version": RESOURCE_CONFIG_VERSION,
|
|
97
|
+
"nodes": {},
|
|
98
|
+
"environments": {},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
def _load_unlocked(self) -> dict[str, Any]:
|
|
102
|
+
if not self.path.exists():
|
|
103
|
+
return self._empty()
|
|
104
|
+
try:
|
|
105
|
+
value = json.loads(self.path.read_text(encoding="utf-8"))
|
|
106
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
107
|
+
raise ValueError(f"invalid resource config {self.path}: {exc}") from exc
|
|
108
|
+
if (
|
|
109
|
+
not isinstance(value, dict)
|
|
110
|
+
or set(value) != {"version", "nodes", "environments"}
|
|
111
|
+
or value.get("version") != RESOURCE_CONFIG_VERSION
|
|
112
|
+
or not isinstance(value.get("nodes"), dict)
|
|
113
|
+
or not isinstance(value.get("environments"), dict)
|
|
114
|
+
):
|
|
115
|
+
raise ValueError(f"unsupported resource config schema in {self.path}")
|
|
116
|
+
nodes: dict[str, dict[str, Any]] = {}
|
|
117
|
+
environments: dict[str, dict[str, Any]] = {}
|
|
118
|
+
try:
|
|
119
|
+
for name, raw in value["nodes"].items():
|
|
120
|
+
node = Node(**raw)
|
|
121
|
+
if node.name != name:
|
|
122
|
+
raise ValueError(f"node key {name!r} does not match its name")
|
|
123
|
+
nodes[name] = asdict(node)
|
|
124
|
+
for name, raw in value["environments"].items():
|
|
125
|
+
environment = Environment(**raw)
|
|
126
|
+
if environment.name != name:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"environment key {name!r} does not match its name"
|
|
129
|
+
)
|
|
130
|
+
if environment.node != "local" and environment.node not in nodes:
|
|
131
|
+
raise ValueError(
|
|
132
|
+
f"environment {name!r} references unknown node "
|
|
133
|
+
f"{environment.node!r}"
|
|
134
|
+
)
|
|
135
|
+
environments[name] = asdict(environment)
|
|
136
|
+
except (TypeError, ValueError) as exc:
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"invalid resource entry in {self.path}: {exc}"
|
|
139
|
+
) from exc
|
|
140
|
+
return {
|
|
141
|
+
"version": RESOURCE_CONFIG_VERSION,
|
|
142
|
+
"nodes": nodes,
|
|
143
|
+
"environments": environments,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
def load(self) -> dict[str, Any]:
|
|
147
|
+
with _exclusive_file_lock(self.lock_path):
|
|
148
|
+
return self._load_unlocked()
|
|
149
|
+
|
|
150
|
+
def _save(self, value: dict[str, Any]) -> None:
|
|
151
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
descriptor, temporary = tempfile.mkstemp(
|
|
153
|
+
prefix=".resources-",
|
|
154
|
+
suffix=".json",
|
|
155
|
+
dir=self.path.parent,
|
|
156
|
+
)
|
|
157
|
+
try:
|
|
158
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
159
|
+
json.dump(value, handle, ensure_ascii=False, indent=2, sort_keys=True)
|
|
160
|
+
handle.write("\n")
|
|
161
|
+
handle.flush()
|
|
162
|
+
os.fsync(handle.fileno())
|
|
163
|
+
os.replace(temporary, self.path)
|
|
164
|
+
finally:
|
|
165
|
+
try:
|
|
166
|
+
os.unlink(temporary)
|
|
167
|
+
except FileNotFoundError:
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class NodeStore(_ResourceStore):
|
|
172
|
+
def add(
|
|
173
|
+
self,
|
|
174
|
+
node: Node,
|
|
175
|
+
*,
|
|
176
|
+
force: bool = False,
|
|
177
|
+
create_host_environment: bool = False,
|
|
178
|
+
) -> None:
|
|
179
|
+
if node.name == "local":
|
|
180
|
+
raise ValueError("node name 'local' is reserved")
|
|
181
|
+
with _exclusive_file_lock(self.lock_path):
|
|
182
|
+
value = self._load_unlocked()
|
|
183
|
+
if node.name in value["nodes"] and not force:
|
|
184
|
+
raise ValueError(f"node {node.name!r} already exists")
|
|
185
|
+
if (
|
|
186
|
+
create_host_environment
|
|
187
|
+
and node.name in value["environments"]
|
|
188
|
+
and not force
|
|
189
|
+
):
|
|
190
|
+
raise ValueError(
|
|
191
|
+
f"environment {node.name!r} already exists"
|
|
192
|
+
)
|
|
193
|
+
value["nodes"][node.name] = asdict(node)
|
|
194
|
+
if create_host_environment:
|
|
195
|
+
value["environments"][node.name] = asdict(
|
|
196
|
+
Environment(node.name, node.name)
|
|
197
|
+
)
|
|
198
|
+
self._save(value)
|
|
199
|
+
|
|
200
|
+
def get(self, name: str) -> Node:
|
|
201
|
+
raw = self.load()["nodes"].get(name)
|
|
202
|
+
if raw is None:
|
|
203
|
+
raise KeyError(f"unknown node: {name}")
|
|
204
|
+
return Node(**raw)
|
|
205
|
+
|
|
206
|
+
def list(self) -> list[Node]:
|
|
207
|
+
values = self.load()["nodes"]
|
|
208
|
+
return [Node(**values[name]) for name in sorted(values)]
|
|
209
|
+
|
|
210
|
+
def update_variables(
|
|
211
|
+
self,
|
|
212
|
+
name: str,
|
|
213
|
+
*,
|
|
214
|
+
set_values: dict[str, str],
|
|
215
|
+
unset_keys: tuple[str, ...],
|
|
216
|
+
) -> Node:
|
|
217
|
+
with _exclusive_file_lock(self.lock_path):
|
|
218
|
+
value = self._load_unlocked()
|
|
219
|
+
raw = value["nodes"].get(name)
|
|
220
|
+
if raw is None:
|
|
221
|
+
raise KeyError(f"unknown node: {name}")
|
|
222
|
+
node = Node(**raw)
|
|
223
|
+
variables = dict(
|
|
224
|
+
parse_variable_assignment(item)
|
|
225
|
+
for item in node.variables
|
|
226
|
+
)
|
|
227
|
+
for key, item in set_values.items():
|
|
228
|
+
parsed_key, parsed_value = parse_variable_assignment(
|
|
229
|
+
f"{key}={item}"
|
|
230
|
+
)
|
|
231
|
+
variables[parsed_key] = parsed_value
|
|
232
|
+
for key in unset_keys:
|
|
233
|
+
parsed_key, _ = parse_variable_assignment(f"{key}=")
|
|
234
|
+
variables.pop(parsed_key, None)
|
|
235
|
+
updated = replace(
|
|
236
|
+
node,
|
|
237
|
+
variables=tuple(
|
|
238
|
+
f"{key}={variables[key]}" for key in sorted(variables)
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
value["nodes"][name] = asdict(updated)
|
|
242
|
+
self._save(value)
|
|
243
|
+
return updated
|
|
244
|
+
|
|
245
|
+
def remove(self, name: str, *, cascade: bool = False) -> list[str]:
|
|
246
|
+
with _exclusive_file_lock(self.lock_path):
|
|
247
|
+
value = self._load_unlocked()
|
|
248
|
+
if name not in value["nodes"]:
|
|
249
|
+
raise KeyError(f"unknown node: {name}")
|
|
250
|
+
references = sorted(
|
|
251
|
+
environment_name
|
|
252
|
+
for environment_name, raw in value["environments"].items()
|
|
253
|
+
if raw["node"] == name
|
|
254
|
+
)
|
|
255
|
+
if references and not cascade:
|
|
256
|
+
raise ValueError(
|
|
257
|
+
f"node {name!r} is referenced by environments: "
|
|
258
|
+
+ ", ".join(references)
|
|
259
|
+
)
|
|
260
|
+
for environment_name in references:
|
|
261
|
+
del value["environments"][environment_name]
|
|
262
|
+
del value["nodes"][name]
|
|
263
|
+
self._save(value)
|
|
264
|
+
return references
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class EnvironmentStore(_ResourceStore):
|
|
268
|
+
def add(self, environment: Environment, *, force: bool = False) -> None:
|
|
269
|
+
if environment.name == "local":
|
|
270
|
+
raise ValueError("environment name 'local' is reserved")
|
|
271
|
+
with _exclusive_file_lock(self.lock_path):
|
|
272
|
+
value = self._load_unlocked()
|
|
273
|
+
if environment.node != "local" and environment.node not in value["nodes"]:
|
|
274
|
+
raise KeyError(f"unknown node: {environment.node}")
|
|
275
|
+
if environment.name in value["environments"] and not force:
|
|
276
|
+
raise ValueError(
|
|
277
|
+
f"environment {environment.name!r} already exists"
|
|
278
|
+
)
|
|
279
|
+
value["environments"][environment.name] = asdict(environment)
|
|
280
|
+
self._save(value)
|
|
281
|
+
|
|
282
|
+
def get(self, name: str) -> Environment:
|
|
283
|
+
if name == "local":
|
|
284
|
+
return Environment("local", "local")
|
|
285
|
+
raw = self.load()["environments"].get(name)
|
|
286
|
+
if raw is None:
|
|
287
|
+
raise KeyError(f"unknown environment: {name}")
|
|
288
|
+
return Environment(**raw)
|
|
289
|
+
|
|
290
|
+
def list(self) -> list[Environment]:
|
|
291
|
+
values = self.load()["environments"]
|
|
292
|
+
return [Environment(**values[name]) for name in sorted(values)]
|
|
293
|
+
|
|
294
|
+
def remove(self, name: str) -> None:
|
|
295
|
+
with _exclusive_file_lock(self.lock_path):
|
|
296
|
+
value = self._load_unlocked()
|
|
297
|
+
if name not in value["environments"]:
|
|
298
|
+
raise KeyError(f"unknown environment: {name}")
|
|
299
|
+
del value["environments"][name]
|
|
300
|
+
self._save(value)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Typed contracts for shared msdev application services."""
|
|
2
|
+
|
|
3
|
+
from .context import (
|
|
4
|
+
BootstrapNode,
|
|
5
|
+
ServiceContext,
|
|
6
|
+
TransportFactory,
|
|
7
|
+
create_default_transport,
|
|
8
|
+
)
|
|
9
|
+
from .environment import (
|
|
10
|
+
EnvironmentAddRequest,
|
|
11
|
+
EnvironmentListResult,
|
|
12
|
+
EnvironmentService,
|
|
13
|
+
)
|
|
14
|
+
from .execution import ExecRequest, ExecResult, ExecutionService
|
|
15
|
+
from .model import (
|
|
16
|
+
ModelAuditRequest,
|
|
17
|
+
ModelDiscoverRequest,
|
|
18
|
+
ModelExportResult,
|
|
19
|
+
ModelImportRequest,
|
|
20
|
+
ModelListRequest,
|
|
21
|
+
ModelListResult,
|
|
22
|
+
ModelNodeError,
|
|
23
|
+
ModelNodeResult,
|
|
24
|
+
ModelOperationResult,
|
|
25
|
+
ModelRefRequest,
|
|
26
|
+
ModelRegisterRequest,
|
|
27
|
+
ModelRebindRequest,
|
|
28
|
+
ModelRefreshRequest,
|
|
29
|
+
ModelReplicaAddRequest,
|
|
30
|
+
ModelReplicaRemoveRequest,
|
|
31
|
+
ModelService,
|
|
32
|
+
ModelUnregisterRequest,
|
|
33
|
+
ModelUpdateRequest,
|
|
34
|
+
ModelValidateRequest,
|
|
35
|
+
ModelVerifyRequest,
|
|
36
|
+
inventory_from_jsonl,
|
|
37
|
+
inventory_to_jsonl,
|
|
38
|
+
)
|
|
39
|
+
from .npu import NpuListResult, NpuNodeResult, NpuService
|
|
40
|
+
from .node import (
|
|
41
|
+
NodeAddRequest,
|
|
42
|
+
NodeConnectionBatch,
|
|
43
|
+
NodeConnectionResult,
|
|
44
|
+
NodeListResult,
|
|
45
|
+
NodeService,
|
|
46
|
+
)
|
|
47
|
+
from .workspace import (
|
|
48
|
+
WorkspaceAddRequest,
|
|
49
|
+
WorkspaceGlobResult,
|
|
50
|
+
WorkspaceGitResult,
|
|
51
|
+
WorkspaceInspectResult,
|
|
52
|
+
WorkspaceListResult,
|
|
53
|
+
WorkspacePathResult,
|
|
54
|
+
WorkspaceReadResult,
|
|
55
|
+
WorkspaceRemoveResult,
|
|
56
|
+
WorkspaceSearchResult,
|
|
57
|
+
WorkspaceService,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"BootstrapNode",
|
|
62
|
+
"ExecRequest",
|
|
63
|
+
"ExecResult",
|
|
64
|
+
"ExecutionService",
|
|
65
|
+
"EnvironmentAddRequest",
|
|
66
|
+
"EnvironmentListResult",
|
|
67
|
+
"EnvironmentService",
|
|
68
|
+
"NpuListResult",
|
|
69
|
+
"NpuService",
|
|
70
|
+
"NpuNodeResult",
|
|
71
|
+
"ModelAuditRequest",
|
|
72
|
+
"ModelDiscoverRequest",
|
|
73
|
+
"ModelExportResult",
|
|
74
|
+
"ModelImportRequest",
|
|
75
|
+
"ModelListRequest",
|
|
76
|
+
"ModelListResult",
|
|
77
|
+
"ModelNodeError",
|
|
78
|
+
"ModelNodeResult",
|
|
79
|
+
"ModelOperationResult",
|
|
80
|
+
"ModelRefRequest",
|
|
81
|
+
"ModelRegisterRequest",
|
|
82
|
+
"ModelRebindRequest",
|
|
83
|
+
"ModelRefreshRequest",
|
|
84
|
+
"ModelReplicaAddRequest",
|
|
85
|
+
"ModelReplicaRemoveRequest",
|
|
86
|
+
"ModelService",
|
|
87
|
+
"ModelUnregisterRequest",
|
|
88
|
+
"ModelUpdateRequest",
|
|
89
|
+
"ModelValidateRequest",
|
|
90
|
+
"ModelVerifyRequest",
|
|
91
|
+
"ServiceContext",
|
|
92
|
+
"NodeAddRequest",
|
|
93
|
+
"NodeConnectionBatch",
|
|
94
|
+
"NodeConnectionResult",
|
|
95
|
+
"NodeListResult",
|
|
96
|
+
"NodeService",
|
|
97
|
+
"TransportFactory",
|
|
98
|
+
"WorkspaceAddRequest",
|
|
99
|
+
"WorkspaceGlobResult",
|
|
100
|
+
"WorkspaceGitResult",
|
|
101
|
+
"WorkspaceInspectResult",
|
|
102
|
+
"WorkspaceListResult",
|
|
103
|
+
"WorkspacePathResult",
|
|
104
|
+
"WorkspaceReadResult",
|
|
105
|
+
"WorkspaceRemoveResult",
|
|
106
|
+
"WorkspaceSearchResult",
|
|
107
|
+
"WorkspaceService",
|
|
108
|
+
"create_default_transport",
|
|
109
|
+
"inventory_from_jsonl",
|
|
110
|
+
"inventory_to_jsonl",
|
|
111
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Dependencies shared by application services."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
|
|
9
|
+
from ..resources import Environment, EnvironmentStore, Node, NodeStore
|
|
10
|
+
from ..transport import (
|
|
11
|
+
RpcTransport,
|
|
12
|
+
SshRpcTransport,
|
|
13
|
+
UnixRpcTransport,
|
|
14
|
+
bootstrap_node,
|
|
15
|
+
)
|
|
16
|
+
from ..workspace import WorkspaceStore
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TransportFactory(Protocol):
|
|
20
|
+
def __call__(
|
|
21
|
+
self,
|
|
22
|
+
node: Node | None,
|
|
23
|
+
environment: Environment | None = None,
|
|
24
|
+
) -> RpcTransport: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BootstrapNode(Protocol):
|
|
28
|
+
def __call__(
|
|
29
|
+
self,
|
|
30
|
+
node: Node,
|
|
31
|
+
package_path: Path | None = None,
|
|
32
|
+
) -> dict[str, Any]: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_default_transport(
|
|
36
|
+
node: Node | None,
|
|
37
|
+
environment: Environment | None = None,
|
|
38
|
+
) -> RpcTransport:
|
|
39
|
+
if node is None:
|
|
40
|
+
return UnixRpcTransport()
|
|
41
|
+
return SshRpcTransport(node, environment)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class ServiceContext:
|
|
46
|
+
node_store: NodeStore = field(default_factory=NodeStore)
|
|
47
|
+
environment_store: EnvironmentStore = field(default_factory=EnvironmentStore)
|
|
48
|
+
workspace_store: WorkspaceStore = field(default_factory=WorkspaceStore)
|
|
49
|
+
create_transport: TransportFactory = create_default_transport
|
|
50
|
+
bootstrap: BootstrapNode = bootstrap_node
|
|
51
|
+
|
|
52
|
+
def transport_for_node(self, node_name: str) -> RpcTransport:
|
|
53
|
+
node = None if node_name == "local" else self.node_store.get(node_name)
|
|
54
|
+
return self.create_transport(node)
|
|
55
|
+
|
|
56
|
+
def transport_for_environment(self, environment_name: str) -> RpcTransport:
|
|
57
|
+
environment = self.environment_store.get(environment_name)
|
|
58
|
+
if environment.node == "local":
|
|
59
|
+
return self.create_transport(None, environment)
|
|
60
|
+
node = self.node_store.get(environment.node)
|
|
61
|
+
return self.create_transport(node, environment)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Execution environment registry and interactive shell service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..resources import Environment
|
|
9
|
+
from .context import ServiceContext
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class EnvironmentAddRequest:
|
|
14
|
+
name: str
|
|
15
|
+
node: str
|
|
16
|
+
docker_container: str | None = None
|
|
17
|
+
layers: tuple[str, ...] = ()
|
|
18
|
+
force: bool = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class EnvironmentListResult:
|
|
23
|
+
environments: tuple[Environment, ...]
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict[str, Any]:
|
|
26
|
+
return {
|
|
27
|
+
"environments": [
|
|
28
|
+
asdict(environment) for environment in self.environments
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class EnvironmentService:
|
|
34
|
+
def __init__(self, context: ServiceContext):
|
|
35
|
+
self.context = context
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def _required(value: str, label: str) -> str:
|
|
39
|
+
if not isinstance(value, str) or not value.strip():
|
|
40
|
+
raise ValueError(f"{label} must be a non-empty string")
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
def add(self, request: EnvironmentAddRequest) -> Environment:
|
|
44
|
+
if type(request.force) is not bool:
|
|
45
|
+
raise ValueError("force must be a boolean")
|
|
46
|
+
if not isinstance(request.layers, (tuple, list)) or not all(
|
|
47
|
+
isinstance(layer, str) for layer in request.layers
|
|
48
|
+
):
|
|
49
|
+
raise ValueError("layers must be a string array")
|
|
50
|
+
environment = Environment(
|
|
51
|
+
name=self._required(request.name, "environment name"),
|
|
52
|
+
node=self._required(request.node, "node name"),
|
|
53
|
+
runtime="docker" if request.docker_container else "host",
|
|
54
|
+
container=request.docker_container,
|
|
55
|
+
layers=tuple(request.layers),
|
|
56
|
+
)
|
|
57
|
+
self.context.node_store.get(environment.node)
|
|
58
|
+
self.context.environment_store.add(environment, force=request.force)
|
|
59
|
+
return environment
|
|
60
|
+
|
|
61
|
+
def list(self) -> EnvironmentListResult:
|
|
62
|
+
return EnvironmentListResult(
|
|
63
|
+
tuple(self.context.environment_store.list())
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def inspect(self, name: str) -> Environment:
|
|
67
|
+
return self.context.environment_store.get(
|
|
68
|
+
self._required(name, "environment name")
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def remove(self, name: str) -> str:
|
|
72
|
+
environment_name = self._required(name, "environment name")
|
|
73
|
+
self.context.environment_store.remove(environment_name)
|
|
74
|
+
return environment_name
|
|
75
|
+
|
|
76
|
+
def shell(self, name: str, *, cwd: str | None = None) -> int:
|
|
77
|
+
environment_name = self._required(name, "environment name")
|
|
78
|
+
if environment_name == "local":
|
|
79
|
+
raise ValueError("environment 'local' is already the current shell")
|
|
80
|
+
transport = self.context.transport_for_environment(environment_name)
|
|
81
|
+
operation = getattr(transport, "open_shell", None)
|
|
82
|
+
if operation is None:
|
|
83
|
+
raise TypeError("environment transport does not support a shell")
|
|
84
|
+
return int(operation(cwd=cwd))
|