splime 0.4.4__tar.gz → 0.4.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {splime-0.4.4/src/splime.egg-info → splime-0.4.5}/PKG-INFO +36 -5
- {splime-0.4.4 → splime-0.4.5}/README.md +35 -4
- {splime-0.4.4 → splime-0.4.5}/pyproject.toml +1 -1
- {splime-0.4.4 → splime-0.4.5}/src/spl/_client.py +34 -2
- splime-0.4.5/src/spl/_http.py +289 -0
- splime-0.4.5/src/spl/_process.py +128 -0
- splime-0.4.5/src/spl/_timeout.py +49 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/core/_common.py +749 -116
- splime-0.4.5/src/spl/core/_graph.py +36 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/core/adapter_compat.py +313 -30
- {splime-0.4.4 → splime-0.4.5}/src/spl/core/entities/adapter.py +158 -2
- {splime-0.4.4 → splime-0.4.5}/src/spl/core/entities/function.py +2 -2
- {splime-0.4.4 → splime-0.4.5}/src/spl/core/entities/node_remote.py +39 -7
- splime-0.4.5/src/spl/core/entities/pipeline.py +1038 -0
- splime-0.4.5/src/spl/core/entities/scalar.py +86 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/core/fingerprint.py +7 -16
- splime-0.4.5/src/spl/core/json_contract.py +280 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/core/manifest.py +71 -42
- {splime-0.4.4 → splime-0.4.5}/src/spl/core/node_runtime.py +100 -29
- splime-0.4.5/src/spl/core/redaction.py +303 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/core/resume.py +108 -0
- splime-0.4.5/src/spl/daemon/artifact_access.py +249 -0
- splime-0.4.5/src/spl/daemon/callback_capability.py +276 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/canonical.py +20 -10
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/cli.py +40 -3
- splime-0.4.5/src/spl/daemon/docker_pool.py +1331 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/environment.py +2 -5
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/environment_base.py +21 -11
- splime-0.4.5/src/spl/daemon/home_lock.py +520 -0
- splime-0.4.5/src/spl/daemon/metadata.py +1297 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/remote_client.py +163 -60
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/repositories/env.py +2 -5
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/repositories/library.py +75 -34
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/repositories/object.py +81 -4
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/repositories/run.py +421 -15
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/repositories/sync_event.py +222 -5
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/routes/_helpers.py +28 -3
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/routes/artifacts.py +16 -5
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/routes/diagnostics.py +2 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/routes/remote.py +20 -6
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/routes/runs.py +13 -5
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/runtime_backend.py +38 -3
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/runtime_config.py +8 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/runtime_dependencies.py +52 -3
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/secret_store.py +3 -1
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/server.py +1336 -158
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/services/sync.py +11 -1
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/spl_free_runner.py +85 -6
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/storage_base.py +278 -3
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/store.py +72 -8
- splime-0.4.5/src/spl/daemon/telemetry.py +916 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/worker.py +127 -51
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/worker_runtime_marker.py +1 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon_client.py +31 -9
- {splime-0.4.4 → splime-0.4.5}/src/spl/pipeline_widget.py +7 -10
- {splime-0.4.4 → splime-0.4.5}/src/spl/server_client.py +19 -9
- {splime-0.4.4 → splime-0.4.5/src/splime.egg-info}/PKG-INFO +36 -5
- {splime-0.4.4 → splime-0.4.5}/src/splime.egg-info/SOURCES.txt +9 -0
- splime-0.4.4/src/spl/_http.py +0 -123
- splime-0.4.4/src/spl/core/entities/pipeline.py +0 -526
- splime-0.4.4/src/spl/core/entities/scalar.py +0 -48
- splime-0.4.4/src/spl/daemon/docker_pool.py +0 -569
- splime-0.4.4/src/spl/daemon/metadata.py +0 -401
- {splime-0.4.4 → splime-0.4.5}/LICENSE +0 -0
- {splime-0.4.4 → splime-0.4.5}/NOTICE +0 -0
- {splime-0.4.4 → splime-0.4.5}/setup.cfg +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/__init__.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/_deprecate.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/_owner_ref.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/_views.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/client.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/core/__init__.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/core/common.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/core/entities/__init__.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/core/entities/artifact.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/core/entities/control.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/core/entities/distribution.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/core/entities/local_function.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/core/entities/misc.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/core/entities/module.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/core/entities/node.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/core/entities/node_function.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/core/ir/__init__.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/core/ir/common.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/core/ir/parse.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/core/ir/unparse.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/core/ir/utils.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/__init__.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/__main__.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/client.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/docker_environment.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/doctor.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/heartbeat_service.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/interpreter_visibility.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/repositories/__init__.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/repositories/server_connection.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/routes/__init__.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/routes/envs.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/routes/libraries.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/routes/objects.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/routes/server_connections.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/run_lifecycle.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/run_progress.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/server_connection.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/services/__init__.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/signature.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/daemon/spl_free_generator.py +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/spl/py.typed +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/splime.egg-info/dependency_links.txt +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/splime.egg-info/entry_points.txt +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/splime.egg-info/requires.txt +0 -0
- {splime-0.4.4 → splime-0.4.5}/src/splime.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: splime
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.5
|
|
4
4
|
Summary: Reuse Python functions across projects without rewriting or redeploying them.
|
|
5
5
|
Author-email: Yastrebov Kirill <yastrebovks@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -76,11 +76,14 @@ redeploying.
|
|
|
76
76
|
## Requirements
|
|
77
77
|
|
|
78
78
|
- Python **3.13+**
|
|
79
|
+
- POSIX for local daemon environment builds and timeout-safe worker execution
|
|
80
|
+
in 0.4.5 (Windows Job Object support is not yet implemented; Windows
|
|
81
|
+
client-only use is unaffected)
|
|
79
82
|
|
|
80
83
|
## Install
|
|
81
84
|
|
|
82
85
|
```bash
|
|
83
|
-
pip install splime
|
|
86
|
+
python3.13 -m pip install "splime==0.4.5"
|
|
84
87
|
```
|
|
85
88
|
|
|
86
89
|
The distribution is named `splime`; the Python import package is `spl`.
|
|
@@ -119,7 +122,7 @@ it by name and get back the value (plus logs and any artifacts).
|
|
|
119
122
|
|
|
120
123
|
The same `call` becomes a remote run when you point it at a library, an owner, or a
|
|
121
124
|
target machine. This requires a connected splime server and a private worker; the local
|
|
122
|
-
daemon builds
|
|
125
|
+
daemon builds the declared dependency environment on the worker before executing.
|
|
123
126
|
|
|
124
127
|
```python
|
|
125
128
|
client = SPLClient(user_token="…", machine_token="…") # connect the daemon to your server
|
|
@@ -157,10 +160,38 @@ splime runs code that you publish on purpose, on machines you control. It is bui
|
|
|
157
160
|
|
|
158
161
|
- **explicit ownership** of every published object,
|
|
159
162
|
- **scoped access** grants per library (read metadata, read objects, execute),
|
|
160
|
-
- **private worker
|
|
161
|
-
- **
|
|
163
|
+
- **private worker topology** — the server coordinates, your own workers execute,
|
|
164
|
+
- **dependency environments** built by the daemon before a run,
|
|
165
|
+
- **metadata-only central telemetry by default** — local inputs, results,
|
|
166
|
+
error details, streams, and artifact bodies stay on the machine; diagnostic
|
|
167
|
+
adds only redacted/truncated error and stdout/stderr text, while full also
|
|
168
|
+
opts in to redacted inputs, results, and supported text artifacts,
|
|
162
169
|
- an **auditable run history**.
|
|
163
170
|
|
|
171
|
+
Credentialed central-server traffic requires a direct HTTPS endpoint and never
|
|
172
|
+
follows redirects. Loopback HTTP remains available for local development; the
|
|
173
|
+
only other plaintext path is the exact Docker callback carrying only a scoped
|
|
174
|
+
run capability.
|
|
175
|
+
|
|
176
|
+
Native and `venv-subprocess` runtimes execute trusted code under the
|
|
177
|
+
conductor's OS identity—the daemon user for daemon-managed runs. A virtual
|
|
178
|
+
environment separates dependencies and a subprocess separates execution, but
|
|
179
|
+
neither is an OS sandbox. Docker or a deliberately separate OS identity is
|
|
180
|
+
required when code must not read same-UID daemon files. Docker provides the
|
|
181
|
+
configured process/filesystem boundary, subject to its mounts and network
|
|
182
|
+
options and to trust in the Docker daemon and host.
|
|
183
|
+
|
|
184
|
+
Scoped callback capabilities limit the authority intentionally passed over the
|
|
185
|
+
worker protocol. They do not protect against arbitrary same-UID file reads and
|
|
186
|
+
do not turn a native or virtual-environment worker into a sandbox.
|
|
187
|
+
|
|
188
|
+
Docker object runs use one container per run by default and mount only that
|
|
189
|
+
run's writable directory. Warm pooling is explicitly opt-in with
|
|
190
|
+
`spl-daemon serve --docker-pool-enabled --docker-pool-size N`; pooled
|
|
191
|
+
containers share all runs for that daemon and are suitable only for
|
|
192
|
+
single-tenant, mutually trusting workloads. One process also exclusively locks
|
|
193
|
+
each daemon home before opening its database or publishing its endpoint.
|
|
194
|
+
|
|
164
195
|
## How it fits together
|
|
165
196
|
|
|
166
197
|
| Piece | What it does |
|
|
@@ -21,11 +21,14 @@ redeploying.
|
|
|
21
21
|
## Requirements
|
|
22
22
|
|
|
23
23
|
- Python **3.13+**
|
|
24
|
+
- POSIX for local daemon environment builds and timeout-safe worker execution
|
|
25
|
+
in 0.4.5 (Windows Job Object support is not yet implemented; Windows
|
|
26
|
+
client-only use is unaffected)
|
|
24
27
|
|
|
25
28
|
## Install
|
|
26
29
|
|
|
27
30
|
```bash
|
|
28
|
-
pip install splime
|
|
31
|
+
python3.13 -m pip install "splime==0.4.5"
|
|
29
32
|
```
|
|
30
33
|
|
|
31
34
|
The distribution is named `splime`; the Python import package is `spl`.
|
|
@@ -64,7 +67,7 @@ it by name and get back the value (plus logs and any artifacts).
|
|
|
64
67
|
|
|
65
68
|
The same `call` becomes a remote run when you point it at a library, an owner, or a
|
|
66
69
|
target machine. This requires a connected splime server and a private worker; the local
|
|
67
|
-
daemon builds
|
|
70
|
+
daemon builds the declared dependency environment on the worker before executing.
|
|
68
71
|
|
|
69
72
|
```python
|
|
70
73
|
client = SPLClient(user_token="…", machine_token="…") # connect the daemon to your server
|
|
@@ -102,10 +105,38 @@ splime runs code that you publish on purpose, on machines you control. It is bui
|
|
|
102
105
|
|
|
103
106
|
- **explicit ownership** of every published object,
|
|
104
107
|
- **scoped access** grants per library (read metadata, read objects, execute),
|
|
105
|
-
- **private worker
|
|
106
|
-
- **
|
|
108
|
+
- **private worker topology** — the server coordinates, your own workers execute,
|
|
109
|
+
- **dependency environments** built by the daemon before a run,
|
|
110
|
+
- **metadata-only central telemetry by default** — local inputs, results,
|
|
111
|
+
error details, streams, and artifact bodies stay on the machine; diagnostic
|
|
112
|
+
adds only redacted/truncated error and stdout/stderr text, while full also
|
|
113
|
+
opts in to redacted inputs, results, and supported text artifacts,
|
|
107
114
|
- an **auditable run history**.
|
|
108
115
|
|
|
116
|
+
Credentialed central-server traffic requires a direct HTTPS endpoint and never
|
|
117
|
+
follows redirects. Loopback HTTP remains available for local development; the
|
|
118
|
+
only other plaintext path is the exact Docker callback carrying only a scoped
|
|
119
|
+
run capability.
|
|
120
|
+
|
|
121
|
+
Native and `venv-subprocess` runtimes execute trusted code under the
|
|
122
|
+
conductor's OS identity—the daemon user for daemon-managed runs. A virtual
|
|
123
|
+
environment separates dependencies and a subprocess separates execution, but
|
|
124
|
+
neither is an OS sandbox. Docker or a deliberately separate OS identity is
|
|
125
|
+
required when code must not read same-UID daemon files. Docker provides the
|
|
126
|
+
configured process/filesystem boundary, subject to its mounts and network
|
|
127
|
+
options and to trust in the Docker daemon and host.
|
|
128
|
+
|
|
129
|
+
Scoped callback capabilities limit the authority intentionally passed over the
|
|
130
|
+
worker protocol. They do not protect against arbitrary same-UID file reads and
|
|
131
|
+
do not turn a native or virtual-environment worker into a sandbox.
|
|
132
|
+
|
|
133
|
+
Docker object runs use one container per run by default and mount only that
|
|
134
|
+
run's writable directory. Warm pooling is explicitly opt-in with
|
|
135
|
+
`spl-daemon serve --docker-pool-enabled --docker-pool-size N`; pooled
|
|
136
|
+
containers share all runs for that daemon and are suitable only for
|
|
137
|
+
single-tenant, mutually trusting workloads. One process also exclusively locks
|
|
138
|
+
each daemon home before opening its database or publishing its endpoint.
|
|
139
|
+
|
|
109
140
|
## How it fits together
|
|
110
141
|
|
|
111
142
|
| Piece | What it does |
|
|
@@ -20,6 +20,7 @@ from __future__ import annotations
|
|
|
20
20
|
|
|
21
21
|
import builtins
|
|
22
22
|
import re
|
|
23
|
+
import warnings
|
|
23
24
|
from collections.abc import Mapping
|
|
24
25
|
from dataclasses import dataclass, field, replace
|
|
25
26
|
from html import escape
|
|
@@ -735,6 +736,30 @@ class RemoteRun:
|
|
|
735
736
|
)
|
|
736
737
|
return downloaded
|
|
737
738
|
|
|
739
|
+
def _acknowledge_transient_delivery(self) -> None:
|
|
740
|
+
"""Best-effort additive handshake after terminal data is consumed."""
|
|
741
|
+
|
|
742
|
+
if self.server_side:
|
|
743
|
+
return
|
|
744
|
+
try:
|
|
745
|
+
self._client._daemon.acknowledge_run_delivery(self.id)
|
|
746
|
+
except ClientError as exc:
|
|
747
|
+
if exc.status_code in {404, 405}:
|
|
748
|
+
# A 0.4.5 client can still collect from a 0.4.4 daemon. That
|
|
749
|
+
# daemon does not enforce transient cleanup, so no data is at risk.
|
|
750
|
+
return
|
|
751
|
+
warnings.warn(
|
|
752
|
+
f"run {self.id!r} was delivered, but its retention acknowledgment failed: {exc}",
|
|
753
|
+
RuntimeWarning,
|
|
754
|
+
stacklevel=2,
|
|
755
|
+
)
|
|
756
|
+
except Exception as exc:
|
|
757
|
+
warnings.warn(
|
|
758
|
+
f"run {self.id!r} was delivered, but its retention acknowledgment failed: {exc}",
|
|
759
|
+
RuntimeWarning,
|
|
760
|
+
stacklevel=2,
|
|
761
|
+
)
|
|
762
|
+
|
|
738
763
|
def collect(
|
|
739
764
|
self,
|
|
740
765
|
*,
|
|
@@ -753,18 +778,25 @@ class RemoteRun:
|
|
|
753
778
|
timeout_seconds=timeout_seconds,
|
|
754
779
|
progress=progress,
|
|
755
780
|
)
|
|
756
|
-
|
|
781
|
+
status = str(final_state["status"])
|
|
782
|
+
transient = m_manifest.retention_disposition(final_state.get("keep", True), status) == "remove"
|
|
783
|
+
if status != "succeeded":
|
|
757
784
|
error = final_state.get("error") or "run returned no error message"
|
|
785
|
+
if transient:
|
|
786
|
+
self._acknowledge_transient_delivery()
|
|
758
787
|
raise RuntimeError(f"{self.mode} run {self.id!r} ended as {final_state.get('status')!r}: {error}")
|
|
759
788
|
|
|
760
789
|
payload = self.result()
|
|
761
790
|
downloaded = self.download_artifacts(artifacts_dir) if artifacts_dir is not None else {}
|
|
762
|
-
|
|
791
|
+
result = RemoteResult(
|
|
763
792
|
run=final_state,
|
|
764
793
|
payload=payload,
|
|
765
794
|
mode=self.mode,
|
|
766
795
|
downloaded_artifacts=downloaded,
|
|
767
796
|
)
|
|
797
|
+
if transient:
|
|
798
|
+
self._acknowledge_transient_delivery()
|
|
799
|
+
return result
|
|
768
800
|
|
|
769
801
|
|
|
770
802
|
class _LibraryAdmin:
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Safe HTTP helpers shared by local-daemon and central-server clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import http.client
|
|
6
|
+
import logging
|
|
7
|
+
import ssl
|
|
8
|
+
from functools import lru_cache, partial
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.error import HTTPError
|
|
11
|
+
from urllib.parse import urlsplit
|
|
12
|
+
from urllib.request import (
|
|
13
|
+
HTTPHandler,
|
|
14
|
+
HTTPRedirectHandler,
|
|
15
|
+
HTTPSHandler,
|
|
16
|
+
ProxyHandler,
|
|
17
|
+
Request,
|
|
18
|
+
build_opener,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
DEFAULT_CONNECT_TIMEOUT_SECONDS = 10.0
|
|
22
|
+
DEFAULT_HTTP_TIMEOUT_SECONDS = 60.0
|
|
23
|
+
DEFAULT_FILE_TRANSFER_TIMEOUT_SECONDS = 300.0
|
|
24
|
+
|
|
25
|
+
_LOOPBACK_HTTP_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
|
26
|
+
_DOCKER_CALLBACK_HTTP_HOST = "host.docker.internal"
|
|
27
|
+
_DOCKER_CALLBACK_PATH = "/remote-nodes/run"
|
|
28
|
+
_CREDENTIAL_HEADER_NAMES = frozenset(
|
|
29
|
+
{
|
|
30
|
+
"authorization",
|
|
31
|
+
"cookie",
|
|
32
|
+
"proxy-authorization",
|
|
33
|
+
"x-spl-claim",
|
|
34
|
+
"x-spl-user-token",
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
_LOGGER = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class InsecureCredentialTransportError(ValueError):
|
|
41
|
+
"""Reject a credentialed request that would use unsafe plaintext HTTP."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _NoRedirectHandler(HTTPRedirectHandler):
|
|
45
|
+
"""Surface redirects without replaying a request or any of its headers."""
|
|
46
|
+
|
|
47
|
+
def redirect_request(
|
|
48
|
+
self,
|
|
49
|
+
request: Request,
|
|
50
|
+
file_pointer: Any,
|
|
51
|
+
code: int,
|
|
52
|
+
message: str,
|
|
53
|
+
headers: Any,
|
|
54
|
+
new_url: str,
|
|
55
|
+
) -> Request | None:
|
|
56
|
+
del message, new_url
|
|
57
|
+
raise HTTPError(
|
|
58
|
+
request.full_url,
|
|
59
|
+
code,
|
|
60
|
+
(
|
|
61
|
+
f"HTTP {code} redirect refused: SPL HTTP requests do not follow redirects; "
|
|
62
|
+
"configure the client with the final HTTPS endpoint instead"
|
|
63
|
+
),
|
|
64
|
+
headers,
|
|
65
|
+
file_pointer,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@lru_cache(maxsize=1)
|
|
70
|
+
def _warn_loopback_http_once() -> None:
|
|
71
|
+
_LOGGER.warning(
|
|
72
|
+
"SPL is sending credentials over plaintext HTTP to an explicit loopback development target; "
|
|
73
|
+
"use HTTPS outside local development"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@lru_cache(maxsize=1)
|
|
78
|
+
def _warn_docker_callback_http_once() -> None:
|
|
79
|
+
_LOGGER.warning(
|
|
80
|
+
"SPL is sending a run-scoped callback capability over plaintext HTTP to the local Docker host bridge"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _credential_header_names(request: Request) -> set[str]:
|
|
85
|
+
return {
|
|
86
|
+
name.casefold()
|
|
87
|
+
for name, value in request.header_items()
|
|
88
|
+
if value and name.casefold() in _CREDENTIAL_HEADER_NAMES
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _authorization_bearer_token(request: Request) -> str | None:
|
|
93
|
+
"""Return one syntactically valid bearer token without logging it."""
|
|
94
|
+
|
|
95
|
+
authorization = next(
|
|
96
|
+
(value for name, value in request.header_items() if name.casefold() == "authorization"),
|
|
97
|
+
"",
|
|
98
|
+
)
|
|
99
|
+
scheme, separator, token = authorization.partition(" ")
|
|
100
|
+
if scheme.casefold() != "bearer" or not separator or not token:
|
|
101
|
+
return None
|
|
102
|
+
return token
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _validate_transport(
|
|
106
|
+
request: Request,
|
|
107
|
+
*,
|
|
108
|
+
allow_docker_callback_http: bool,
|
|
109
|
+
contains_credentials: bool,
|
|
110
|
+
) -> bool:
|
|
111
|
+
"""Validate one request and return whether environment proxies must be bypassed."""
|
|
112
|
+
|
|
113
|
+
parsed = urlsplit(request.full_url)
|
|
114
|
+
scheme = parsed.scheme.casefold()
|
|
115
|
+
host = (parsed.hostname or "").casefold()
|
|
116
|
+
credential_headers = _credential_header_names(request)
|
|
117
|
+
url_contains_credentials = parsed.username is not None or parsed.password is not None
|
|
118
|
+
is_credentialed = contains_credentials or bool(credential_headers) or url_contains_credentials
|
|
119
|
+
|
|
120
|
+
if scheme not in {"http", "https"} or not host:
|
|
121
|
+
raise ValueError("SPL HTTP request URLs must use an absolute http:// or https:// URL with a host")
|
|
122
|
+
|
|
123
|
+
if allow_docker_callback_http:
|
|
124
|
+
# Import lazily to keep the generic transport module out of the
|
|
125
|
+
# daemon package's compatibility-import cycle while sharing the exact
|
|
126
|
+
# token namespace with the authority that mints it.
|
|
127
|
+
from spl.daemon.callback_capability import CALLBACK_CAPABILITY_PREFIX
|
|
128
|
+
|
|
129
|
+
bearer_token = _authorization_bearer_token(request)
|
|
130
|
+
docker_callback_is_valid = (
|
|
131
|
+
scheme == "http"
|
|
132
|
+
and host == _DOCKER_CALLBACK_HTTP_HOST
|
|
133
|
+
and parsed.path == _DOCKER_CALLBACK_PATH
|
|
134
|
+
and not parsed.query
|
|
135
|
+
and not parsed.fragment
|
|
136
|
+
and request.get_method().upper() == "POST"
|
|
137
|
+
and bearer_token is not None
|
|
138
|
+
and bearer_token.startswith(CALLBACK_CAPABILITY_PREFIX)
|
|
139
|
+
and len(bearer_token) > len(CALLBACK_CAPABILITY_PREFIX)
|
|
140
|
+
and credential_headers == {"authorization"}
|
|
141
|
+
and not contains_credentials
|
|
142
|
+
)
|
|
143
|
+
if not docker_callback_is_valid:
|
|
144
|
+
raise InsecureCredentialTransportError(
|
|
145
|
+
"the Docker plaintext callback exception is limited to a POST authenticated with a "
|
|
146
|
+
"Bearer splcb_... run callback capability to "
|
|
147
|
+
"http://host.docker.internal/remote-nodes/run without a query or any other credential"
|
|
148
|
+
)
|
|
149
|
+
_warn_docker_callback_http_once()
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
is_loopback = host in _LOOPBACK_HTTP_HOSTS
|
|
153
|
+
if scheme == "http" and is_credentialed:
|
|
154
|
+
if not is_loopback:
|
|
155
|
+
raise InsecureCredentialTransportError(
|
|
156
|
+
"credentialed SPL HTTP requests require HTTPS; plaintext is allowed only for the explicit "
|
|
157
|
+
"loopback development hosts 127.0.0.1, ::1, and localhost"
|
|
158
|
+
)
|
|
159
|
+
_warn_loopback_http_once()
|
|
160
|
+
|
|
161
|
+
return is_loopback
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class ConnectionPhaseError(OSError):
|
|
165
|
+
"""Wrap a failure proven to occur while establishing the connection."""
|
|
166
|
+
|
|
167
|
+
def __init__(self, cause: OSError):
|
|
168
|
+
self.cause = cause
|
|
169
|
+
super().__init__(str(cause))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class _PhaseAwareHTTPConnection(http.client.HTTPConnection):
|
|
173
|
+
def __init__(
|
|
174
|
+
self,
|
|
175
|
+
host: str,
|
|
176
|
+
*,
|
|
177
|
+
read_timeout: float | None,
|
|
178
|
+
**kwargs: Any,
|
|
179
|
+
) -> None:
|
|
180
|
+
self._spl_read_timeout = read_timeout
|
|
181
|
+
super().__init__(host, **kwargs)
|
|
182
|
+
|
|
183
|
+
def connect(self) -> None:
|
|
184
|
+
try:
|
|
185
|
+
super().connect()
|
|
186
|
+
except OSError as exc:
|
|
187
|
+
raise ConnectionPhaseError(exc) from exc
|
|
188
|
+
if self.sock is not None:
|
|
189
|
+
self.sock.settimeout(self._spl_read_timeout)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class _PhaseAwareHTTPSConnection(http.client.HTTPSConnection):
|
|
193
|
+
def __init__(
|
|
194
|
+
self,
|
|
195
|
+
host: str,
|
|
196
|
+
*,
|
|
197
|
+
read_timeout: float | None,
|
|
198
|
+
**kwargs: Any,
|
|
199
|
+
) -> None:
|
|
200
|
+
self._spl_read_timeout = read_timeout
|
|
201
|
+
super().__init__(host, **kwargs)
|
|
202
|
+
|
|
203
|
+
def connect(self) -> None:
|
|
204
|
+
try:
|
|
205
|
+
# HTTPSConnection.connect includes TCP connect, any proxy tunnel,
|
|
206
|
+
# and the TLS handshake. No application request has been sent yet.
|
|
207
|
+
super().connect()
|
|
208
|
+
except OSError as exc:
|
|
209
|
+
raise ConnectionPhaseError(exc) from exc
|
|
210
|
+
if self.sock is not None:
|
|
211
|
+
self.sock.settimeout(self._spl_read_timeout)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class _SplitTimeoutHTTPHandler(HTTPHandler):
|
|
215
|
+
def __init__(self, *, read_timeout: float | None) -> None:
|
|
216
|
+
super().__init__()
|
|
217
|
+
self._spl_read_timeout = read_timeout
|
|
218
|
+
|
|
219
|
+
def http_open(self, request: Request) -> Any:
|
|
220
|
+
connection = partial(
|
|
221
|
+
_PhaseAwareHTTPConnection,
|
|
222
|
+
read_timeout=self._spl_read_timeout,
|
|
223
|
+
)
|
|
224
|
+
return self.do_open(connection, request)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class _SplitTimeoutHTTPSHandler(HTTPSHandler):
|
|
228
|
+
def __init__(
|
|
229
|
+
self,
|
|
230
|
+
*,
|
|
231
|
+
context: ssl.SSLContext,
|
|
232
|
+
read_timeout: float | None,
|
|
233
|
+
) -> None:
|
|
234
|
+
super().__init__(context=context)
|
|
235
|
+
self._spl_context = context
|
|
236
|
+
self._spl_read_timeout = read_timeout
|
|
237
|
+
|
|
238
|
+
def https_open(self, request: Request) -> Any:
|
|
239
|
+
connection = partial(
|
|
240
|
+
_PhaseAwareHTTPSConnection,
|
|
241
|
+
read_timeout=self._spl_read_timeout,
|
|
242
|
+
)
|
|
243
|
+
return self.do_open(connection, request, context=self._spl_context)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@lru_cache(maxsize=1)
|
|
247
|
+
def verified_https_context() -> ssl.SSLContext:
|
|
248
|
+
"""Return a verifying context with environment/system and certifi roots."""
|
|
249
|
+
|
|
250
|
+
import certifi
|
|
251
|
+
|
|
252
|
+
context = ssl.create_default_context()
|
|
253
|
+
context.load_verify_locations(cafile=certifi.where())
|
|
254
|
+
return context
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def urlopen_verified(
|
|
258
|
+
request: Request,
|
|
259
|
+
*,
|
|
260
|
+
timeout: float | None = DEFAULT_HTTP_TIMEOUT_SECONDS,
|
|
261
|
+
connect_timeout: float | None = DEFAULT_CONNECT_TIMEOUT_SECONDS,
|
|
262
|
+
allow_docker_callback_http: bool = False,
|
|
263
|
+
contains_credentials: bool = False,
|
|
264
|
+
) -> Any:
|
|
265
|
+
"""Open a policy-checked request with verified TLS and split timeout budgets.
|
|
266
|
+
|
|
267
|
+
``contains_credentials`` marks authentication material carried in a body
|
|
268
|
+
instead of a recognized header. ``allow_docker_callback_http`` is a narrow
|
|
269
|
+
exception for the run-scoped worker callback route only.
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
bypass_environment_proxies = _validate_transport(
|
|
273
|
+
request,
|
|
274
|
+
allow_docker_callback_http=allow_docker_callback_http,
|
|
275
|
+
contains_credentials=contains_credentials,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
handlers: list[Any] = [
|
|
279
|
+
_NoRedirectHandler(),
|
|
280
|
+
_SplitTimeoutHTTPHandler(read_timeout=timeout),
|
|
281
|
+
_SplitTimeoutHTTPSHandler(
|
|
282
|
+
context=verified_https_context(),
|
|
283
|
+
read_timeout=timeout,
|
|
284
|
+
),
|
|
285
|
+
]
|
|
286
|
+
if bypass_environment_proxies:
|
|
287
|
+
handlers.insert(0, ProxyHandler({}))
|
|
288
|
+
opener = build_opener(*handlers)
|
|
289
|
+
return opener.open(request, timeout=connect_timeout)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Bounded subprocess execution with POSIX process-tree termination."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Mapping, Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import IO
|
|
12
|
+
|
|
13
|
+
from spl._timeout import TimeoutDomain, validate_timeout_seconds
|
|
14
|
+
|
|
15
|
+
DEFAULT_TERMINATION_GRACE_SECONDS = 0.5
|
|
16
|
+
WINDOWS_TIMEOUT_UNSUPPORTED = (
|
|
17
|
+
"finite subprocess timeouts require Windows Job Object support; "
|
|
18
|
+
"timed worker execution is currently supported only on POSIX"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_process_tree(
|
|
23
|
+
command: Sequence[str],
|
|
24
|
+
*,
|
|
25
|
+
cwd: Path | None = None,
|
|
26
|
+
env: Mapping[str, str] | None = None,
|
|
27
|
+
timeout: float | None = None,
|
|
28
|
+
termination_grace_seconds: float = DEFAULT_TERMINATION_GRACE_SECONDS,
|
|
29
|
+
stdout_target: int | IO[str] | None = subprocess.PIPE,
|
|
30
|
+
stderr_target: int | IO[str] | None = subprocess.PIPE,
|
|
31
|
+
) -> subprocess.CompletedProcess[str]:
|
|
32
|
+
"""Run ``command`` and terminate its complete POSIX process group on timeout.
|
|
33
|
+
|
|
34
|
+
A finite timeout fails closed on non-POSIX systems. Windows process groups
|
|
35
|
+
do not contain arbitrary descendants; equivalent support requires assigning
|
|
36
|
+
the child to a Job Object at creation time.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
validate_timeout_seconds(
|
|
40
|
+
timeout,
|
|
41
|
+
name="process timeout",
|
|
42
|
+
domain=TimeoutDomain.FINITE,
|
|
43
|
+
allow_none=True,
|
|
44
|
+
)
|
|
45
|
+
validate_timeout_seconds(
|
|
46
|
+
termination_grace_seconds,
|
|
47
|
+
name="termination_grace_seconds",
|
|
48
|
+
domain=TimeoutDomain.NON_NEGATIVE,
|
|
49
|
+
allow_none=False,
|
|
50
|
+
)
|
|
51
|
+
process_groups_supported = _posix_process_groups_supported()
|
|
52
|
+
if timeout is not None and not process_groups_supported:
|
|
53
|
+
raise RuntimeError(WINDOWS_TIMEOUT_UNSUPPORTED)
|
|
54
|
+
|
|
55
|
+
process = subprocess.Popen(
|
|
56
|
+
list(command),
|
|
57
|
+
cwd=cwd,
|
|
58
|
+
env=env,
|
|
59
|
+
text=True,
|
|
60
|
+
stdout=stdout_target,
|
|
61
|
+
stderr=stderr_target,
|
|
62
|
+
start_new_session=process_groups_supported,
|
|
63
|
+
)
|
|
64
|
+
try:
|
|
65
|
+
stdout_text, stderr_text = process.communicate(timeout=timeout)
|
|
66
|
+
except subprocess.TimeoutExpired as exc:
|
|
67
|
+
stdout_text, stderr_text = _terminate_timed_out_process(
|
|
68
|
+
process,
|
|
69
|
+
grace_seconds=termination_grace_seconds,
|
|
70
|
+
)
|
|
71
|
+
assert timeout is not None
|
|
72
|
+
raise subprocess.TimeoutExpired(
|
|
73
|
+
command,
|
|
74
|
+
timeout,
|
|
75
|
+
output=stdout_text,
|
|
76
|
+
stderr=stderr_text,
|
|
77
|
+
) from exc
|
|
78
|
+
|
|
79
|
+
return subprocess.CompletedProcess(
|
|
80
|
+
list(command),
|
|
81
|
+
process.returncode,
|
|
82
|
+
stdout=stdout_text,
|
|
83
|
+
stderr=stderr_text,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _terminate_timed_out_process(
|
|
88
|
+
process: subprocess.Popen[str],
|
|
89
|
+
*,
|
|
90
|
+
grace_seconds: float,
|
|
91
|
+
) -> tuple[str, str]:
|
|
92
|
+
"""Terminate, force-kill, and reap one timed-out POSIX process group."""
|
|
93
|
+
|
|
94
|
+
_signal_process_group(process.pid, signal.SIGTERM)
|
|
95
|
+
deadline = time.monotonic() + grace_seconds
|
|
96
|
+
while time.monotonic() < deadline:
|
|
97
|
+
if not _process_group_exists(process.pid):
|
|
98
|
+
break
|
|
99
|
+
time.sleep(min(0.01, max(0.0, deadline - time.monotonic())))
|
|
100
|
+
_signal_process_group(process.pid, signal.SIGKILL)
|
|
101
|
+
stdout, stderr = process.communicate()
|
|
102
|
+
return stdout or "", stderr or ""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _posix_process_groups_supported() -> bool:
|
|
106
|
+
return os.name == "posix"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _process_group_exists(process_group_id: int) -> bool:
|
|
110
|
+
try:
|
|
111
|
+
os.killpg(process_group_id, 0)
|
|
112
|
+
except ProcessLookupError:
|
|
113
|
+
return False
|
|
114
|
+
except PermissionError:
|
|
115
|
+
return True
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _signal_process_group(process_group_id: int, sig: signal.Signals) -> None:
|
|
120
|
+
try:
|
|
121
|
+
os.killpg(process_group_id, sig)
|
|
122
|
+
except ProcessLookupError:
|
|
123
|
+
return
|
|
124
|
+
except PermissionError:
|
|
125
|
+
# macOS can report EPERM for a process group containing only a zombie
|
|
126
|
+
# leader between termination and the final wait. The leader is still
|
|
127
|
+
# reaped by ``communicate`` below; a live same-user group is killable.
|
|
128
|
+
return
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Shared validation for timeout values that must be safe for runtime APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TimeoutDomain(StrEnum):
|
|
10
|
+
"""Supported numeric domains for timeout settings."""
|
|
11
|
+
|
|
12
|
+
FINITE = "finite"
|
|
13
|
+
NON_NEGATIVE = "non-negative"
|
|
14
|
+
POSITIVE = "positive"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validate_timeout_seconds(
|
|
18
|
+
value: object,
|
|
19
|
+
*,
|
|
20
|
+
name: str,
|
|
21
|
+
domain: TimeoutDomain,
|
|
22
|
+
allow_none: bool,
|
|
23
|
+
) -> float | None:
|
|
24
|
+
"""Return ``value`` as a finite float when it satisfies ``domain``."""
|
|
25
|
+
|
|
26
|
+
requirement = {
|
|
27
|
+
TimeoutDomain.FINITE: "a finite number",
|
|
28
|
+
TimeoutDomain.NON_NEGATIVE: "a finite non-negative number",
|
|
29
|
+
TimeoutDomain.POSITIVE: "positive and finite",
|
|
30
|
+
}[domain]
|
|
31
|
+
suffix = " or None" if allow_none else ""
|
|
32
|
+
if value is None:
|
|
33
|
+
if allow_none:
|
|
34
|
+
return None
|
|
35
|
+
raise ValueError(f"{name} must be {requirement}")
|
|
36
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
37
|
+
raise ValueError(f"{name} must be {requirement}{suffix}")
|
|
38
|
+
try:
|
|
39
|
+
normalized = float(value)
|
|
40
|
+
except (OverflowError, ValueError) as exc:
|
|
41
|
+
raise ValueError(f"{name} must be {requirement}{suffix}") from exc
|
|
42
|
+
invalid = (
|
|
43
|
+
not math.isfinite(normalized)
|
|
44
|
+
or (domain is TimeoutDomain.NON_NEGATIVE and normalized < 0)
|
|
45
|
+
or (domain is TimeoutDomain.POSITIVE and normalized <= 0)
|
|
46
|
+
)
|
|
47
|
+
if invalid:
|
|
48
|
+
raise ValueError(f"{name} must be {requirement}{suffix}")
|
|
49
|
+
return normalized
|