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/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
@@ -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))