splime 0.1.2__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.
- spl/__init__.py +14 -0
- spl/client.py +1364 -0
- spl/core/__init__.py +23 -0
- spl/core/common.py +350 -0
- spl/core/entities/__init__.py +0 -0
- spl/core/entities/adapter.py +210 -0
- spl/core/entities/artifact.py +141 -0
- spl/core/entities/control.py +45 -0
- spl/core/entities/distribution.py +65 -0
- spl/core/entities/function.py +254 -0
- spl/core/entities/local_function.py +286 -0
- spl/core/entities/misc.py +14 -0
- spl/core/entities/module.py +88 -0
- spl/core/entities/node.py +286 -0
- spl/core/entities/node_function.py +79 -0
- spl/core/entities/node_remote.py +295 -0
- spl/core/entities/pipeline.py +436 -0
- spl/core/entities/scalar.py +55 -0
- spl/core/ir/__init__.py +0 -0
- spl/core/ir/common.py +34 -0
- spl/core/ir/parse.py +79 -0
- spl/core/ir/unparse.py +29 -0
- spl/core/ir/utils.py +163 -0
- spl/daemon/__init__.py +23 -0
- spl/daemon/__main__.py +11 -0
- spl/daemon/cli.py +582 -0
- spl/daemon/client.py +43 -0
- spl/daemon/docker_environment.py +329 -0
- spl/daemon/docker_pool.py +516 -0
- spl/daemon/environment.py +228 -0
- spl/daemon/environment_base.py +479 -0
- spl/daemon/heartbeat_service.py +119 -0
- spl/daemon/metadata.py +427 -0
- spl/daemon/remote_client.py +457 -0
- spl/daemon/repositories/__init__.py +17 -0
- spl/daemon/repositories/env.py +323 -0
- spl/daemon/repositories/library.py +181 -0
- spl/daemon/repositories/object.py +997 -0
- spl/daemon/repositories/run.py +279 -0
- spl/daemon/repositories/server_connection.py +657 -0
- spl/daemon/repositories/sync_event.py +129 -0
- spl/daemon/routes/__init__.py +1 -0
- spl/daemon/routes/_helpers.py +147 -0
- spl/daemon/routes/artifacts.py +77 -0
- spl/daemon/routes/diagnostics.py +114 -0
- spl/daemon/routes/envs.py +82 -0
- spl/daemon/routes/libraries.py +129 -0
- spl/daemon/routes/objects.py +174 -0
- spl/daemon/routes/remote.py +56 -0
- spl/daemon/routes/runs.py +96 -0
- spl/daemon/routes/server_connections.py +86 -0
- spl/daemon/runtime_backend.py +368 -0
- spl/daemon/runtime_config.py +133 -0
- spl/daemon/runtime_dependencies.py +459 -0
- spl/daemon/secret_store.py +187 -0
- spl/daemon/server.py +2224 -0
- spl/daemon/server_connection.py +267 -0
- spl/daemon/services/__init__.py +1 -0
- spl/daemon/services/sync.py +76 -0
- spl/daemon/signature.py +376 -0
- spl/daemon/storage_base.py +542 -0
- spl/daemon/store.py +436 -0
- spl/daemon/worker.py +526 -0
- spl/daemon_client.py +945 -0
- spl/pipeline_widget.py +1452 -0
- spl/py.typed +0 -0
- spl/server_client.py +787 -0
- splime-0.1.2.dist-info/METADATA +189 -0
- splime-0.1.2.dist-info/RECORD +74 -0
- splime-0.1.2.dist-info/WHEEL +5 -0
- splime-0.1.2.dist-info/entry_points.txt +2 -0
- splime-0.1.2.dist-info/licenses/LICENSE +201 -0
- splime-0.1.2.dist-info/licenses/NOTICE +8 -0
- splime-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""Protocols for DaemonRuntime collaborators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
7
|
+
|
|
8
|
+
from spl.daemon.environment_base import EnvironmentManagerProtocol
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from spl.daemon.runtime_backend import RunContext
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DockerEnvironmentManagerProtocol(EnvironmentManagerProtocol, Protocol):
|
|
15
|
+
"""Docker environment manager surface used by the daemon runtime."""
|
|
16
|
+
|
|
17
|
+
def prune_images(self, spec_hash: str | None = None) -> list[dict[str, Any]]:
|
|
18
|
+
"""Remove cached Docker images and mark their build records absent."""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DockerEnvironmentBuilderProtocol(Protocol):
|
|
23
|
+
"""Docker environment build surface needed by Docker runtime components."""
|
|
24
|
+
|
|
25
|
+
def status_for_object(self, object_record: dict[str, Any]) -> dict[str, Any]:
|
|
26
|
+
"""Return the cached Docker build status for an object version."""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
def ensure_ready(
|
|
30
|
+
self,
|
|
31
|
+
object_record: dict[str, Any],
|
|
32
|
+
*,
|
|
33
|
+
wait: bool,
|
|
34
|
+
retry_failed: bool = False,
|
|
35
|
+
) -> dict[str, Any]:
|
|
36
|
+
"""Prepare a Docker image and return its build record."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DockerPoolRunnerProtocol(Protocol):
|
|
41
|
+
"""Docker pool surface needed by the Docker runtime backend."""
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def should_prewarm(self) -> bool:
|
|
45
|
+
"""Return whether objects should be prewarmed after prepare."""
|
|
46
|
+
...
|
|
47
|
+
|
|
48
|
+
def can_use(self, run_dir: Path, workdir: Path) -> bool:
|
|
49
|
+
"""Return whether a run can use a warm pooled container."""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
def ensure_container(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
object_record: dict[str, Any],
|
|
56
|
+
image_tag: str,
|
|
57
|
+
runtime_config: dict[str, Any],
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
"""Return a warm pooled container record."""
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
def exec_worker_command(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
object_record: dict[str, Any],
|
|
66
|
+
entrypoint: str,
|
|
67
|
+
run_id: str,
|
|
68
|
+
container_name: str,
|
|
69
|
+
runtime_config: dict[str, Any],
|
|
70
|
+
) -> list[str]:
|
|
71
|
+
"""Build the command for executing inside a pooled container."""
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
def worker_command(
|
|
75
|
+
self,
|
|
76
|
+
*,
|
|
77
|
+
object_record: dict[str, Any],
|
|
78
|
+
entrypoint: str,
|
|
79
|
+
run_id: str,
|
|
80
|
+
run_dir: Path,
|
|
81
|
+
workdir: Path,
|
|
82
|
+
image_tag: str,
|
|
83
|
+
container_name: str,
|
|
84
|
+
runtime_config: dict[str, Any],
|
|
85
|
+
) -> list[str]:
|
|
86
|
+
"""Build the command for a one-shot Docker container."""
|
|
87
|
+
...
|
|
88
|
+
|
|
89
|
+
def use_container(self, record: dict[str, Any]) -> Any:
|
|
90
|
+
"""Return a context manager marking a pooled container in use."""
|
|
91
|
+
...
|
|
92
|
+
|
|
93
|
+
def remove_container(self, name: str) -> None:
|
|
94
|
+
"""Remove a Docker container by name."""
|
|
95
|
+
...
|
|
96
|
+
|
|
97
|
+
def prewarm_object(self, object_record: dict[str, Any]) -> None:
|
|
98
|
+
"""Start asynchronous prewarming for an object."""
|
|
99
|
+
...
|
|
100
|
+
|
|
101
|
+
def cleanup_stale_containers(self) -> None:
|
|
102
|
+
"""Remove stale warm containers left by previous daemon processes."""
|
|
103
|
+
...
|
|
104
|
+
|
|
105
|
+
def shutdown(self) -> None:
|
|
106
|
+
"""Stop all warm containers owned by this pool."""
|
|
107
|
+
...
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class RuntimeBackendProtocol(Protocol):
|
|
111
|
+
"""Worker runtime backend contract used by the daemon executor."""
|
|
112
|
+
|
|
113
|
+
def __enter__(self) -> RuntimeBackendProtocol:
|
|
114
|
+
"""Enter backend-specific run lifecycle."""
|
|
115
|
+
...
|
|
116
|
+
|
|
117
|
+
def __exit__(
|
|
118
|
+
self,
|
|
119
|
+
exc_type: type[BaseException] | None,
|
|
120
|
+
exc: BaseException | None,
|
|
121
|
+
traceback: Any,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Leave backend-specific run lifecycle."""
|
|
124
|
+
...
|
|
125
|
+
|
|
126
|
+
def status_for_object(self, object_record: dict[str, Any]) -> dict[str, Any]:
|
|
127
|
+
"""Return the cached build status for an object version."""
|
|
128
|
+
...
|
|
129
|
+
|
|
130
|
+
def ensure_ready(
|
|
131
|
+
self,
|
|
132
|
+
object_record: dict[str, Any],
|
|
133
|
+
*,
|
|
134
|
+
wait: bool = True,
|
|
135
|
+
retry_failed: bool = False,
|
|
136
|
+
) -> dict[str, Any]:
|
|
137
|
+
"""Prepare the runtime environment and return its build record."""
|
|
138
|
+
...
|
|
139
|
+
|
|
140
|
+
def build_command(self, ctx: RunContext) -> list[str]:
|
|
141
|
+
"""Return the command used to execute the worker."""
|
|
142
|
+
...
|
|
143
|
+
|
|
144
|
+
def run_state_fields(self) -> dict[str, Any]:
|
|
145
|
+
"""Return backend-specific persisted run fields."""
|
|
146
|
+
...
|
|
147
|
+
|
|
148
|
+
def after_prepare(self, object_record: dict[str, Any]) -> None:
|
|
149
|
+
"""Run optional post-prepare work such as prewarming."""
|
|
150
|
+
...
|
|
151
|
+
|
|
152
|
+
def after_run(self, ctx: RunContext) -> dict[str, Any]:
|
|
153
|
+
"""Return persisted run fields collected after subprocess completion."""
|
|
154
|
+
...
|
|
155
|
+
|
|
156
|
+
def process_result(
|
|
157
|
+
self,
|
|
158
|
+
ctx: RunContext,
|
|
159
|
+
result_payload: dict[str, Any],
|
|
160
|
+
) -> bool:
|
|
161
|
+
"""Mutate a successful worker result and return whether it changed."""
|
|
162
|
+
...
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class SyncVisibilityProtocol(Protocol):
|
|
166
|
+
"""Diagnostic sync visibility surface used by the daemon runtime."""
|
|
167
|
+
|
|
168
|
+
def summary(
|
|
169
|
+
self,
|
|
170
|
+
events: list[dict[str, Any]] | None = None,
|
|
171
|
+
*,
|
|
172
|
+
limit: int = 200,
|
|
173
|
+
) -> dict[str, Any]:
|
|
174
|
+
"""Return a stable summary of pending sync work."""
|
|
175
|
+
...
|
|
176
|
+
|
|
177
|
+
def pending_events(self, *, limit: int = 200) -> list[dict[str, Any]]:
|
|
178
|
+
"""Return decorated pending sync events."""
|
|
179
|
+
...
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class ServerClientProtocol(Protocol):
|
|
183
|
+
"""Central server client surface used by daemon runtime and routes."""
|
|
184
|
+
|
|
185
|
+
def connect_machine(
|
|
186
|
+
self,
|
|
187
|
+
*,
|
|
188
|
+
machine_id: str | None = None,
|
|
189
|
+
display_name: str | None = None,
|
|
190
|
+
capabilities: dict[str, Any] | None = None,
|
|
191
|
+
heartbeat_interval_seconds: float | None = None,
|
|
192
|
+
) -> dict[str, Any]:
|
|
193
|
+
"""Connect this machine to the central server."""
|
|
194
|
+
...
|
|
195
|
+
|
|
196
|
+
def heartbeat_connection(
|
|
197
|
+
self,
|
|
198
|
+
*,
|
|
199
|
+
connection_id: str,
|
|
200
|
+
machine_id: str,
|
|
201
|
+
heartbeat_interval_seconds: float | None = None,
|
|
202
|
+
) -> dict[str, Any]:
|
|
203
|
+
"""Send one server heartbeat."""
|
|
204
|
+
...
|
|
205
|
+
|
|
206
|
+
def disconnect_machine(self) -> dict[str, Any]:
|
|
207
|
+
"""Disconnect this machine from the central server."""
|
|
208
|
+
...
|
|
209
|
+
|
|
210
|
+
def list_machines(self) -> list[dict[str, Any]]:
|
|
211
|
+
"""Return machines visible to the connected identity."""
|
|
212
|
+
...
|
|
213
|
+
|
|
214
|
+
def list_libraries(self, *, include_accessible: bool = True) -> list[dict[str, Any]]:
|
|
215
|
+
"""Return visible server libraries."""
|
|
216
|
+
...
|
|
217
|
+
|
|
218
|
+
def create_library(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
219
|
+
"""Create a server library."""
|
|
220
|
+
...
|
|
221
|
+
|
|
222
|
+
def get_library(self, library_ref: str) -> dict[str, Any]:
|
|
223
|
+
"""Return a server library."""
|
|
224
|
+
...
|
|
225
|
+
|
|
226
|
+
def update_library(self, library_ref: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
227
|
+
"""Update a server library."""
|
|
228
|
+
...
|
|
229
|
+
|
|
230
|
+
def delete_library(self, library_ref: str) -> dict[str, Any]:
|
|
231
|
+
"""Delete a server library."""
|
|
232
|
+
...
|
|
233
|
+
|
|
234
|
+
def list_library_grants(self, library_ref: str) -> list[dict[str, Any]]:
|
|
235
|
+
"""Return library grants."""
|
|
236
|
+
...
|
|
237
|
+
|
|
238
|
+
def grant_library(self, library_ref: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
239
|
+
"""Grant library access."""
|
|
240
|
+
...
|
|
241
|
+
|
|
242
|
+
def revoke_library_grant(self, library_ref: str, grantee: str) -> dict[str, Any]:
|
|
243
|
+
"""Revoke library access."""
|
|
244
|
+
...
|
|
245
|
+
|
|
246
|
+
def add_library_reference(
|
|
247
|
+
self,
|
|
248
|
+
library_ref: str,
|
|
249
|
+
payload: dict[str, Any],
|
|
250
|
+
) -> dict[str, Any]:
|
|
251
|
+
"""Add an object reference to a library."""
|
|
252
|
+
...
|
|
253
|
+
|
|
254
|
+
def copy_object_into_library(
|
|
255
|
+
self,
|
|
256
|
+
library_ref: str,
|
|
257
|
+
payload: dict[str, Any],
|
|
258
|
+
) -> dict[str, Any]:
|
|
259
|
+
"""Copy an object into a library."""
|
|
260
|
+
...
|
|
261
|
+
|
|
262
|
+
def remove_library_entry(self, library_ref: str, name: str) -> dict[str, Any]:
|
|
263
|
+
"""Remove an object from a library."""
|
|
264
|
+
...
|
|
265
|
+
|
|
266
|
+
def list_objects(
|
|
267
|
+
self,
|
|
268
|
+
*,
|
|
269
|
+
owner_id: str | None = None,
|
|
270
|
+
library: str | None = None,
|
|
271
|
+
compact: bool = False,
|
|
272
|
+
) -> list[dict[str, Any]]:
|
|
273
|
+
"""Return server objects."""
|
|
274
|
+
...
|
|
275
|
+
|
|
276
|
+
def latest_machine_library_snapshot(
|
|
277
|
+
self,
|
|
278
|
+
machine_id: str,
|
|
279
|
+
*,
|
|
280
|
+
include_yaml: bool = False,
|
|
281
|
+
) -> dict[str, Any]:
|
|
282
|
+
"""Return the latest machine library snapshot."""
|
|
283
|
+
...
|
|
284
|
+
|
|
285
|
+
def get_object(
|
|
286
|
+
self,
|
|
287
|
+
name_or_id: str,
|
|
288
|
+
*,
|
|
289
|
+
version: int | None = None,
|
|
290
|
+
include_yaml: bool = False,
|
|
291
|
+
owner_id: str | None = None,
|
|
292
|
+
library: str | None = None,
|
|
293
|
+
) -> dict[str, Any]:
|
|
294
|
+
"""Return a server object."""
|
|
295
|
+
...
|
|
296
|
+
|
|
297
|
+
def object_signature(
|
|
298
|
+
self,
|
|
299
|
+
name_or_id: str,
|
|
300
|
+
*,
|
|
301
|
+
version: int | None = None,
|
|
302
|
+
owner_id: str | None = None,
|
|
303
|
+
library: str | None = None,
|
|
304
|
+
function: str | None = None,
|
|
305
|
+
) -> dict[str, Any]:
|
|
306
|
+
"""Return a server object signature."""
|
|
307
|
+
...
|
|
308
|
+
|
|
309
|
+
def list_object_versions(
|
|
310
|
+
self,
|
|
311
|
+
name_or_id: str,
|
|
312
|
+
*,
|
|
313
|
+
include_yaml: bool = False,
|
|
314
|
+
owner_id: str | None = None,
|
|
315
|
+
library: str | None = None,
|
|
316
|
+
) -> list[dict[str, Any]]:
|
|
317
|
+
"""Return server object versions."""
|
|
318
|
+
...
|
|
319
|
+
|
|
320
|
+
def sync(
|
|
321
|
+
self,
|
|
322
|
+
*,
|
|
323
|
+
connection_id: str,
|
|
324
|
+
machine_id: str,
|
|
325
|
+
heartbeat_interval_seconds: float,
|
|
326
|
+
events: list[dict[str, Any]],
|
|
327
|
+
capabilities: dict[str, Any] | None = None,
|
|
328
|
+
) -> dict[str, Any]:
|
|
329
|
+
"""Send one sync request."""
|
|
330
|
+
...
|
|
331
|
+
|
|
332
|
+
def get_remote_run(self, run_id: str) -> dict[str, Any]:
|
|
333
|
+
"""Return a remote run."""
|
|
334
|
+
...
|
|
335
|
+
|
|
336
|
+
def list_artifacts(self, run_id: str) -> list[dict[str, Any]]:
|
|
337
|
+
"""Return remote run artifacts."""
|
|
338
|
+
...
|
|
339
|
+
|
|
340
|
+
def upload_artifact(self, run_id: str, name: str, path: str | Path) -> dict[str, Any]:
|
|
341
|
+
"""Upload a remote run artifact."""
|
|
342
|
+
...
|
|
343
|
+
|
|
344
|
+
def artifact_bytes(self, run_id: str, name: str) -> bytes:
|
|
345
|
+
"""Return remote artifact bytes."""
|
|
346
|
+
...
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
class ServerClientFactoryProtocol(Protocol):
|
|
350
|
+
"""Factory for central server clients."""
|
|
351
|
+
|
|
352
|
+
def __call__(
|
|
353
|
+
self,
|
|
354
|
+
base_url: str,
|
|
355
|
+
machine_token: str,
|
|
356
|
+
*,
|
|
357
|
+
user_token: str | None = None,
|
|
358
|
+
) -> ServerClientProtocol:
|
|
359
|
+
"""Return a central server client."""
|
|
360
|
+
...
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class ServerConnectionsProtocol(Protocol):
|
|
364
|
+
"""Server connection lifecycle surface used by daemon runtime."""
|
|
365
|
+
|
|
366
|
+
def server_client(
|
|
367
|
+
self,
|
|
368
|
+
server_url: str,
|
|
369
|
+
token: str,
|
|
370
|
+
*,
|
|
371
|
+
user_token: str | None,
|
|
372
|
+
) -> ServerClientProtocol:
|
|
373
|
+
"""Return a central server client for explicit credentials."""
|
|
374
|
+
...
|
|
375
|
+
|
|
376
|
+
def server_client_for_credentials(
|
|
377
|
+
self,
|
|
378
|
+
credentials: dict[str, Any],
|
|
379
|
+
) -> ServerClientProtocol:
|
|
380
|
+
"""Return a central server client for stored credentials."""
|
|
381
|
+
...
|
|
382
|
+
|
|
383
|
+
def connect_server(
|
|
384
|
+
self,
|
|
385
|
+
*,
|
|
386
|
+
server_url: str,
|
|
387
|
+
machine_token: str,
|
|
388
|
+
user_token: str,
|
|
389
|
+
machine_id: str | None,
|
|
390
|
+
display_name: str | None,
|
|
391
|
+
capabilities: dict[str, Any],
|
|
392
|
+
heartbeat_interval_seconds: float | None,
|
|
393
|
+
) -> dict[str, Any]:
|
|
394
|
+
"""Connect to the central daemon server."""
|
|
395
|
+
...
|
|
396
|
+
|
|
397
|
+
def disconnect_server(
|
|
398
|
+
self,
|
|
399
|
+
credentials: dict[str, Any] | None = None,
|
|
400
|
+
) -> dict[str, Any]:
|
|
401
|
+
"""Disconnect from the central daemon server."""
|
|
402
|
+
...
|
|
403
|
+
|
|
404
|
+
def matching_server_connection(
|
|
405
|
+
self,
|
|
406
|
+
*,
|
|
407
|
+
server_url: str,
|
|
408
|
+
machine_token: str,
|
|
409
|
+
user_token: str,
|
|
410
|
+
machine_id: str | None,
|
|
411
|
+
) -> dict[str, Any] | None:
|
|
412
|
+
"""Return matching stored server credentials, if any."""
|
|
413
|
+
...
|
|
414
|
+
|
|
415
|
+
def restore_pending_server_connection(
|
|
416
|
+
self,
|
|
417
|
+
credentials: dict[str, Any],
|
|
418
|
+
) -> dict[str, Any]:
|
|
419
|
+
"""Reconnect a pending offline server connection."""
|
|
420
|
+
...
|
|
421
|
+
|
|
422
|
+
def require_connected_server_credentials(
|
|
423
|
+
self,
|
|
424
|
+
credentials: dict[str, Any] | None = None,
|
|
425
|
+
) -> dict[str, Any]:
|
|
426
|
+
"""Return connected server credentials or raise."""
|
|
427
|
+
...
|
|
428
|
+
|
|
429
|
+
def remote_connection_snapshot(
|
|
430
|
+
self,
|
|
431
|
+
connection: dict[str, Any],
|
|
432
|
+
) -> dict[str, Any]:
|
|
433
|
+
"""Return a server-shaped snapshot for a stored connection."""
|
|
434
|
+
...
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
class HeartbeatsProtocol(Protocol):
|
|
438
|
+
"""Server heartbeat lifecycle surface used by daemon runtime."""
|
|
439
|
+
|
|
440
|
+
def restore_server_heartbeat(self) -> None:
|
|
441
|
+
"""Restore heartbeat for the current connection."""
|
|
442
|
+
...
|
|
443
|
+
|
|
444
|
+
def start_server_heartbeat(
|
|
445
|
+
self,
|
|
446
|
+
connection: dict[str, Any],
|
|
447
|
+
*,
|
|
448
|
+
token: str,
|
|
449
|
+
) -> None:
|
|
450
|
+
"""Start heartbeat for one connection."""
|
|
451
|
+
...
|
|
452
|
+
|
|
453
|
+
def stop_server_heartbeat(self, connection_id: str) -> None:
|
|
454
|
+
"""Stop heartbeat for one connection."""
|
|
455
|
+
...
|
|
456
|
+
|
|
457
|
+
def shutdown(self) -> None:
|
|
458
|
+
"""Stop all heartbeat activity."""
|
|
459
|
+
...
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Secret storage for local daemon credentials.
|
|
2
|
+
|
|
3
|
+
The daemon keeps metadata in SQLite, but central-server tokens should live in
|
|
4
|
+
an OS credential store when one is available. A 0600 file backend exists for
|
|
5
|
+
tests and headless containers where an OS keychain is unavailable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Protocol
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
SECRET_BACKEND_ENV = "SPL_DAEMON_SECRET_BACKEND"
|
|
17
|
+
KEYRING_SERVICE = "io.splime.daemon"
|
|
18
|
+
FILE_BACKEND_NAME = "file"
|
|
19
|
+
KEYRING_BACKEND_NAME = "keyring"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SecretStoreError(RuntimeError):
|
|
23
|
+
"""Raised when a daemon secret cannot be read or written."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SecretBackendProtocol(Protocol):
|
|
27
|
+
name: str
|
|
28
|
+
|
|
29
|
+
def get(self, key: str) -> str | None:
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
def set(self, key: str, value: str) -> None:
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
def delete(self, key: str) -> None:
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class KeyringSecretBackend:
|
|
40
|
+
name = KEYRING_BACKEND_NAME
|
|
41
|
+
|
|
42
|
+
def __init__(self, home: Path):
|
|
43
|
+
try:
|
|
44
|
+
import keyring
|
|
45
|
+
import keyring.errors
|
|
46
|
+
except ModuleNotFoundError as exc:
|
|
47
|
+
raise SecretStoreError("keyring package is not installed") from exc
|
|
48
|
+
self._keyring = keyring
|
|
49
|
+
self._errors = keyring.errors
|
|
50
|
+
self._prefix = home.absolute().as_posix()
|
|
51
|
+
|
|
52
|
+
def _account(self, key: str) -> str:
|
|
53
|
+
return f"{self._prefix}:{key}"
|
|
54
|
+
|
|
55
|
+
def get(self, key: str) -> str | None:
|
|
56
|
+
try:
|
|
57
|
+
return self._keyring.get_password(KEYRING_SERVICE, self._account(key))
|
|
58
|
+
except self._errors.KeyringError as exc:
|
|
59
|
+
raise SecretStoreError(str(exc)) from exc
|
|
60
|
+
|
|
61
|
+
def set(self, key: str, value: str) -> None:
|
|
62
|
+
try:
|
|
63
|
+
self._keyring.set_password(KEYRING_SERVICE, self._account(key), value)
|
|
64
|
+
except self._errors.KeyringError as exc:
|
|
65
|
+
raise SecretStoreError(str(exc)) from exc
|
|
66
|
+
|
|
67
|
+
def delete(self, key: str) -> None:
|
|
68
|
+
try:
|
|
69
|
+
self._keyring.delete_password(KEYRING_SERVICE, self._account(key))
|
|
70
|
+
except self._errors.PasswordDeleteError:
|
|
71
|
+
return
|
|
72
|
+
except self._errors.KeyringError as exc:
|
|
73
|
+
raise SecretStoreError(str(exc)) from exc
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class FileSecretBackend:
|
|
77
|
+
name = FILE_BACKEND_NAME
|
|
78
|
+
|
|
79
|
+
def __init__(self, home: Path):
|
|
80
|
+
self.path = home / "daemon-secrets.json"
|
|
81
|
+
|
|
82
|
+
def _read(self) -> dict[str, str]:
|
|
83
|
+
try:
|
|
84
|
+
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
|
85
|
+
except FileNotFoundError:
|
|
86
|
+
return {}
|
|
87
|
+
if not isinstance(raw, dict):
|
|
88
|
+
raise SecretStoreError(f"secret file is not a JSON object: {self.path}")
|
|
89
|
+
return {str(key): str(value) for key, value in raw.items()}
|
|
90
|
+
|
|
91
|
+
def _write(self, values: dict[str, str]) -> None:
|
|
92
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
tmp_path = self.path.with_name(f"{self.path.name}.tmp")
|
|
94
|
+
tmp_path.write_text(
|
|
95
|
+
json.dumps(values, ensure_ascii=False, indent=2, sort_keys=True),
|
|
96
|
+
encoding="utf-8",
|
|
97
|
+
)
|
|
98
|
+
try:
|
|
99
|
+
tmp_path.chmod(0o600)
|
|
100
|
+
except OSError:
|
|
101
|
+
pass
|
|
102
|
+
tmp_path.replace(self.path)
|
|
103
|
+
try:
|
|
104
|
+
self.path.chmod(0o600)
|
|
105
|
+
except OSError:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
def get(self, key: str) -> str | None:
|
|
109
|
+
return self._read().get(key)
|
|
110
|
+
|
|
111
|
+
def set(self, key: str, value: str) -> None:
|
|
112
|
+
values = self._read()
|
|
113
|
+
values[key] = value
|
|
114
|
+
self._write(values)
|
|
115
|
+
|
|
116
|
+
def delete(self, key: str) -> None:
|
|
117
|
+
values = self._read()
|
|
118
|
+
if key not in values:
|
|
119
|
+
return
|
|
120
|
+
del values[key]
|
|
121
|
+
self._write(values)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class SecretStore:
|
|
125
|
+
"""Read and write daemon secrets through keychain-backed refs."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, home: Path):
|
|
128
|
+
self.home = home
|
|
129
|
+
self._fallback = FileSecretBackend(home)
|
|
130
|
+
backend = os.environ.get(SECRET_BACKEND_ENV, "auto").strip().lower()
|
|
131
|
+
if backend == FILE_BACKEND_NAME:
|
|
132
|
+
self._write_backends: list[SecretBackendProtocol] = [self._fallback]
|
|
133
|
+
elif backend in {"keyring", "os", "keychain"}:
|
|
134
|
+
self._write_backends = [KeyringSecretBackend(home)]
|
|
135
|
+
elif backend in {"", "auto"}:
|
|
136
|
+
self._write_backends = self._auto_backends(home)
|
|
137
|
+
else:
|
|
138
|
+
raise SecretStoreError(f"unknown daemon secret backend: {backend}")
|
|
139
|
+
self._read_backends: dict[str, SecretBackendProtocol] = {
|
|
140
|
+
FILE_BACKEND_NAME: self._fallback,
|
|
141
|
+
}
|
|
142
|
+
for candidate in self._write_backends:
|
|
143
|
+
self._read_backends[candidate.name] = candidate
|
|
144
|
+
|
|
145
|
+
def _auto_backends(self, home: Path) -> list[SecretBackendProtocol]:
|
|
146
|
+
try:
|
|
147
|
+
return [KeyringSecretBackend(home), self._fallback]
|
|
148
|
+
except SecretStoreError:
|
|
149
|
+
return [self._fallback]
|
|
150
|
+
|
|
151
|
+
def put(self, key: str, value: str) -> str:
|
|
152
|
+
errors: list[str] = []
|
|
153
|
+
for backend in self._write_backends:
|
|
154
|
+
try:
|
|
155
|
+
backend.set(key, value)
|
|
156
|
+
return f"{backend.name}:{key}"
|
|
157
|
+
except SecretStoreError as exc:
|
|
158
|
+
errors.append(f"{backend.name}: {exc}")
|
|
159
|
+
raise SecretStoreError("; ".join(errors) or "no secret backend available")
|
|
160
|
+
|
|
161
|
+
def get(self, ref: str) -> str:
|
|
162
|
+
backend_name, key = self._split_ref(ref)
|
|
163
|
+
backend = self._read_backends.get(backend_name)
|
|
164
|
+
if backend is None:
|
|
165
|
+
raise SecretStoreError(f"unknown secret backend in ref: {backend_name}")
|
|
166
|
+
value = backend.get(key)
|
|
167
|
+
if value is None:
|
|
168
|
+
raise SecretStoreError(f"daemon secret is not found: {ref}")
|
|
169
|
+
return value
|
|
170
|
+
|
|
171
|
+
def delete(self, ref: str | None) -> None:
|
|
172
|
+
if not ref:
|
|
173
|
+
return
|
|
174
|
+
backend_name, key = self._split_ref(ref)
|
|
175
|
+
backend = self._read_backends.get(backend_name)
|
|
176
|
+
if backend is None:
|
|
177
|
+
return
|
|
178
|
+
backend.delete(key)
|
|
179
|
+
|
|
180
|
+
def _split_ref(self, ref: str) -> tuple[str, str]:
|
|
181
|
+
try:
|
|
182
|
+
backend_name, key = ref.split(":", 1)
|
|
183
|
+
except ValueError as exc:
|
|
184
|
+
raise SecretStoreError(f"invalid daemon secret ref: {ref}") from exc
|
|
185
|
+
if not backend_name or not key:
|
|
186
|
+
raise SecretStoreError(f"invalid daemon secret ref: {ref}")
|
|
187
|
+
return backend_name, key
|