bazaar-compute-node 0.1.3__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.
- bazaar_compute_node/__init__.py +3 -0
- bazaar_compute_node/app/__init__.py +1 -0
- bazaar_compute_node/app/application.py +398 -0
- bazaar_compute_node/app/attachments.py +154 -0
- bazaar_compute_node/app/command.py +342 -0
- bazaar_compute_node/app/config.py +121 -0
- bazaar_compute_node/app/registry.py +120 -0
- bazaar_compute_node/app/transport.py +264 -0
- bazaar_compute_node/app/windows_pipe.py +463 -0
- bazaar_compute_node/app/wrapper.py +63 -0
- bazaar_compute_node/bcc.py +524 -0
- bazaar_compute_node/cli.py +382 -0
- bazaar_compute_node/contrib/__init__.py +1 -0
- bazaar_compute_node/contrib/codex_app_server/__init__.py +63 -0
- bazaar_compute_node/contrib/codex_app_server/approval.py +168 -0
- bazaar_compute_node/contrib/codex_app_server/client.py +408 -0
- bazaar_compute_node/contrib/codex_app_server/events.py +431 -0
- bazaar_compute_node/contrib/codex_app_server/plugin.py +15 -0
- bazaar_compute_node/contrib/codex_app_server/process.py +583 -0
- bazaar_compute_node/contrib/codex_app_server/protocol.py +103 -0
- bazaar_compute_node/contrib/codex_app_server/runtime.py +513 -0
- bazaar_compute_node/contrib/logging/__init__.py +5 -0
- bazaar_compute_node/contrib/logging/audit.py +61 -0
- bazaar_compute_node/contrib/logging/plugin.py +11 -0
- bazaar_compute_node/contrib/sqlite/__init__.py +14 -0
- bazaar_compute_node/contrib/sqlite/codec.py +768 -0
- bazaar_compute_node/contrib/sqlite/database.py +282 -0
- bazaar_compute_node/contrib/sqlite/migrations.py +646 -0
- bazaar_compute_node/contrib/sqlite/plugin.py +11 -0
- bazaar_compute_node/contrib/sqlite/repository.py +1059 -0
- bazaar_compute_node/contrib/wecom/__init__.py +1 -0
- bazaar_compute_node/contrib/wecom/channel.py +960 -0
- bazaar_compute_node/contrib/wecom/markdown.py +146 -0
- bazaar_compute_node/contrib/wecom/plugin.py +29 -0
- bazaar_compute_node/core/__init__.py +5 -0
- bazaar_compute_node/core/approval.py +51 -0
- bazaar_compute_node/core/audit.py +101 -0
- bazaar_compute_node/core/channel.py +121 -0
- bazaar_compute_node/core/client.py +30 -0
- bazaar_compute_node/core/command.py +85 -0
- bazaar_compute_node/core/concurrency.py +29 -0
- bazaar_compute_node/core/correlation.py +48 -0
- bazaar_compute_node/core/instruction.py +224 -0
- bazaar_compute_node/core/lifecycle.py +48 -0
- bazaar_compute_node/core/models/__init__.py +63 -0
- bazaar_compute_node/core/models/entities.py +514 -0
- bazaar_compute_node/core/models/states.py +369 -0
- bazaar_compute_node/core/observability.py +47 -0
- bazaar_compute_node/core/orchestration/__init__.py +5 -0
- bazaar_compute_node/core/orchestration/command.py +614 -0
- bazaar_compute_node/core/orchestration/services.py +135 -0
- bazaar_compute_node/core/orchestration/session.py +891 -0
- bazaar_compute_node/core/orchestration/turn.py +451 -0
- bazaar_compute_node/core/outcomes.py +51 -0
- bazaar_compute_node/core/paths.py +19 -0
- bazaar_compute_node/core/runtime.py +118 -0
- bazaar_compute_node/core/storage.py +167 -0
- bazaar_compute_node-0.1.3.dist-info/METADATA +178 -0
- bazaar_compute_node-0.1.3.dist-info/RECORD +62 -0
- bazaar_compute_node-0.1.3.dist-info/WHEEL +4 -0
- bazaar_compute_node-0.1.3.dist-info/entry_points.txt +15 -0
- bazaar_compute_node-0.1.3.dist-info/licenses/LICENSE +613 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Application composition and local command transport."""
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hmac
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import secrets
|
|
8
|
+
import signal
|
|
9
|
+
from collections.abc import Mapping, Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ..core.channel import ChannelContext, IChannel
|
|
13
|
+
from ..core.lifecycle import TimeoutBudget
|
|
14
|
+
from ..core.models import RuntimeSession
|
|
15
|
+
from ..core.observability import IAudit
|
|
16
|
+
from ..core.orchestration import SessionOrchestrator
|
|
17
|
+
from ..core.paths import resolve_data_dir, resolve_workspace_dir
|
|
18
|
+
from ..core.runtime import IRuntime, RuntimeCommandContext, RuntimeSandboxMode
|
|
19
|
+
from ..core.storage import IStorage, NodeIdentity
|
|
20
|
+
from .attachments import AttachmentMaterializer
|
|
21
|
+
from .command import (
|
|
22
|
+
CommandDispatcher,
|
|
23
|
+
CommandDispatchError,
|
|
24
|
+
)
|
|
25
|
+
from .registry import AdapterFactories
|
|
26
|
+
from .transport import LocalCommandServer
|
|
27
|
+
from .wrapper import install_bcc_wrapper, remove_bcc_wrapper
|
|
28
|
+
|
|
29
|
+
CommandRecord = tuple[str, tuple[str, ...]]
|
|
30
|
+
|
|
31
|
+
_ENVIRONMENT_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
32
|
+
_PLATFORM_ENVIRONMENT = {
|
|
33
|
+
"HOME",
|
|
34
|
+
"LANG",
|
|
35
|
+
"LC_ALL",
|
|
36
|
+
"PATH",
|
|
37
|
+
"TMPDIR",
|
|
38
|
+
"TEMP",
|
|
39
|
+
"TMP",
|
|
40
|
+
"SystemRoot",
|
|
41
|
+
"ComSpec",
|
|
42
|
+
"PATHEXT",
|
|
43
|
+
"USERPROFILE",
|
|
44
|
+
}
|
|
45
|
+
_FORBIDDEN_ENVIRONMENT = {
|
|
46
|
+
"BCN_WECOM_BOT_SECRET",
|
|
47
|
+
"DATABASE_URL",
|
|
48
|
+
"AWS_ACCESS_KEY_ID",
|
|
49
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
50
|
+
"OPENAI_API_KEY",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class NodeApplication:
|
|
55
|
+
"""Generic application lifecycle for one dynamically composed node."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
factories: AdapterFactories,
|
|
61
|
+
endpoint_path: Path | None = None,
|
|
62
|
+
node_id: str = "bcn-node",
|
|
63
|
+
workspace_id: str | None = None,
|
|
64
|
+
runtime_options: Mapping[str, str] | None = None,
|
|
65
|
+
runtime_sandbox_mode: RuntimeSandboxMode = RuntimeSandboxMode.WORKSPACE_WRITE,
|
|
66
|
+
runtime_network_access: bool = True,
|
|
67
|
+
channel_options: Mapping[str, object] | None = None,
|
|
68
|
+
runtime_environment_include: Sequence[str] = (),
|
|
69
|
+
timeout_budget: TimeoutBudget | None = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
self.data_dir = resolve_data_dir()
|
|
72
|
+
self.runtime_options = dict(runtime_options or {})
|
|
73
|
+
self.timeout_budget = timeout_budget or TimeoutBudget(
|
|
74
|
+
startup_seconds=60,
|
|
75
|
+
provider_call_seconds=600,
|
|
76
|
+
command_seconds=10,
|
|
77
|
+
shutdown_seconds=5,
|
|
78
|
+
)
|
|
79
|
+
self.storage: IStorage = factories.storage()
|
|
80
|
+
self.audit: IAudit = factories.audit()
|
|
81
|
+
self.command_log: list[CommandRecord] = []
|
|
82
|
+
self._wrapper_path: Path | None = None
|
|
83
|
+
self._identity: NodeIdentity | None = None
|
|
84
|
+
self._session_capabilities: dict[str, str] = {}
|
|
85
|
+
self._runtime_session_ids: dict[str, str] = {}
|
|
86
|
+
self._started = False
|
|
87
|
+
self._stopped = asyncio.Event()
|
|
88
|
+
self._runtime_environment_include = tuple(runtime_environment_include)
|
|
89
|
+
self._attachment_materializer = AttachmentMaterializer(
|
|
90
|
+
self._workspace_path, self._referenced_attachment_paths
|
|
91
|
+
)
|
|
92
|
+
self.channel: IChannel = factories.channel(
|
|
93
|
+
ChannelContext(
|
|
94
|
+
attachments=self._attachment_materializer,
|
|
95
|
+
options=dict(channel_options or {}),
|
|
96
|
+
workspace=self._workspace_path,
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
self._runtime_context = RuntimeCommandContext(
|
|
100
|
+
run_command=self._run_runtime_command,
|
|
101
|
+
environment_for_session=self._runtime_environment,
|
|
102
|
+
node_id=node_id,
|
|
103
|
+
runtime_options=self.runtime_options,
|
|
104
|
+
sandbox_mode=runtime_sandbox_mode,
|
|
105
|
+
network_access=runtime_network_access,
|
|
106
|
+
startup_timeout_seconds=self.timeout_budget.startup_seconds,
|
|
107
|
+
)
|
|
108
|
+
self.runtime: IRuntime = factories.runtime(self._runtime_context)
|
|
109
|
+
self.orchestrator = SessionOrchestrator(
|
|
110
|
+
node_id=node_id,
|
|
111
|
+
workspace_id=workspace_id,
|
|
112
|
+
channel=self.channel,
|
|
113
|
+
runtime=self.runtime,
|
|
114
|
+
storage=self.storage,
|
|
115
|
+
audit=self.audit,
|
|
116
|
+
timeout_budget=self.timeout_budget,
|
|
117
|
+
on_node_initialized=self._ensure_workspace,
|
|
118
|
+
)
|
|
119
|
+
self.command_service = self.orchestrator.command_service
|
|
120
|
+
control_handler = None
|
|
121
|
+
if factories.control is not None:
|
|
122
|
+
control_handler = factories.control(self._adapter_context())
|
|
123
|
+
self.command_dispatcher = CommandDispatcher(
|
|
124
|
+
self.command_service,
|
|
125
|
+
timeout_budget=self.timeout_budget,
|
|
126
|
+
control_handler=self._handle_control,
|
|
127
|
+
session_binding_validator=self._validate_session_binding,
|
|
128
|
+
)
|
|
129
|
+
self.command_server = LocalCommandServer(
|
|
130
|
+
self.command_dispatcher,
|
|
131
|
+
endpoint_path=endpoint_path,
|
|
132
|
+
)
|
|
133
|
+
self._provider_control_handler = control_handler
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def endpoint(self) -> str:
|
|
137
|
+
return self.command_server.endpoint
|
|
138
|
+
|
|
139
|
+
async def start(self) -> None:
|
|
140
|
+
if self._started:
|
|
141
|
+
return
|
|
142
|
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
if os.name != "nt":
|
|
144
|
+
self.data_dir.chmod(0o700)
|
|
145
|
+
self._wrapper_path = install_bcc_wrapper(self.data_dir / "bin")
|
|
146
|
+
try:
|
|
147
|
+
await self.command_server.start()
|
|
148
|
+
await self.orchestrator.start(
|
|
149
|
+
timeout=self.timeout_budget.startup_seconds,
|
|
150
|
+
)
|
|
151
|
+
except BaseException:
|
|
152
|
+
await self.stop()
|
|
153
|
+
raise
|
|
154
|
+
self._started = True
|
|
155
|
+
self._stopped.clear()
|
|
156
|
+
self.command_dispatcher.start_accepting()
|
|
157
|
+
|
|
158
|
+
async def stop(self) -> None:
|
|
159
|
+
if not self._started:
|
|
160
|
+
try:
|
|
161
|
+
await self.command_dispatcher.drain(
|
|
162
|
+
timeout=self.timeout_budget.shutdown_seconds,
|
|
163
|
+
)
|
|
164
|
+
finally:
|
|
165
|
+
try:
|
|
166
|
+
await self.command_server.stop()
|
|
167
|
+
finally:
|
|
168
|
+
self._cleanup_bcc_wrapper()
|
|
169
|
+
return
|
|
170
|
+
self._started = False
|
|
171
|
+
self.command_dispatcher.stop_accepting()
|
|
172
|
+
try:
|
|
173
|
+
await self.command_dispatcher.drain(
|
|
174
|
+
timeout=self.timeout_budget.shutdown_seconds,
|
|
175
|
+
)
|
|
176
|
+
finally:
|
|
177
|
+
try:
|
|
178
|
+
await self.orchestrator.stop(
|
|
179
|
+
timeout=self.timeout_budget.shutdown_seconds,
|
|
180
|
+
)
|
|
181
|
+
finally:
|
|
182
|
+
try:
|
|
183
|
+
await self.command_server.stop()
|
|
184
|
+
finally:
|
|
185
|
+
try:
|
|
186
|
+
self._cleanup_bcc_wrapper()
|
|
187
|
+
finally:
|
|
188
|
+
self._stopped.set()
|
|
189
|
+
|
|
190
|
+
def _cleanup_bcc_wrapper(self) -> None:
|
|
191
|
+
wrapper_path = self._wrapper_path
|
|
192
|
+
if wrapper_path is None:
|
|
193
|
+
return
|
|
194
|
+
remove_bcc_wrapper(wrapper_path)
|
|
195
|
+
self._wrapper_path = None
|
|
196
|
+
|
|
197
|
+
async def wait(self) -> None:
|
|
198
|
+
loop = asyncio.get_running_loop()
|
|
199
|
+
for signum in (signal.SIGINT, signal.SIGTERM):
|
|
200
|
+
try:
|
|
201
|
+
loop.add_signal_handler(signum, self._stopped.set)
|
|
202
|
+
except NotImplementedError, RuntimeError:
|
|
203
|
+
pass
|
|
204
|
+
await self._stopped.wait()
|
|
205
|
+
|
|
206
|
+
async def _ensure_workspace(self, identity: NodeIdentity) -> None:
|
|
207
|
+
self._identity = identity
|
|
208
|
+
workspace_dir = resolve_workspace_dir(identity.workspace_id)
|
|
209
|
+
await asyncio.to_thread(
|
|
210
|
+
workspace_dir.mkdir,
|
|
211
|
+
parents=True,
|
|
212
|
+
exist_ok=True,
|
|
213
|
+
mode=0o700,
|
|
214
|
+
)
|
|
215
|
+
if os.name != "nt":
|
|
216
|
+
await asyncio.to_thread(workspace_dir.chmod, 0o700)
|
|
217
|
+
await self._attachment_materializer.reconcile()
|
|
218
|
+
|
|
219
|
+
def _workspace_path(self) -> Path:
|
|
220
|
+
identity = self._identity
|
|
221
|
+
if identity is None:
|
|
222
|
+
raise RuntimeError("node identity has not been initialized")
|
|
223
|
+
return resolve_workspace_dir(identity.workspace_id)
|
|
224
|
+
|
|
225
|
+
async def _referenced_attachment_paths(self) -> set[str]:
|
|
226
|
+
async with self.storage.transaction() as transaction:
|
|
227
|
+
return set(await transaction.list_ready_attachment_paths())
|
|
228
|
+
|
|
229
|
+
def _adapter_context(self) -> Mapping[str, object]:
|
|
230
|
+
return {
|
|
231
|
+
"channel": self.channel,
|
|
232
|
+
"runtime": self.runtime,
|
|
233
|
+
"storage": self.storage,
|
|
234
|
+
"audit": self.audit,
|
|
235
|
+
"command_log": self.command_log,
|
|
236
|
+
"is_started": lambda: self._started,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async def _handle_control(
|
|
240
|
+
self, request: Mapping[str, object]
|
|
241
|
+
) -> Mapping[str, object]:
|
|
242
|
+
if request.get("operation") == "health":
|
|
243
|
+
identity = self._identity
|
|
244
|
+
return {
|
|
245
|
+
"started": self._started,
|
|
246
|
+
"accepting": self.command_dispatcher.accepting,
|
|
247
|
+
"channel": self.channel.name,
|
|
248
|
+
"channel_health": dict(self.channel.health),
|
|
249
|
+
"runtime": self.runtime.name,
|
|
250
|
+
"storage": self.storage.name,
|
|
251
|
+
"audit": self.audit.name,
|
|
252
|
+
"node_id": identity.node_id if identity is not None else None,
|
|
253
|
+
"workspace_id": (
|
|
254
|
+
identity.workspace_id if identity is not None else None
|
|
255
|
+
),
|
|
256
|
+
}
|
|
257
|
+
if request.get("operation") == "shutdown":
|
|
258
|
+
self._stopped.set()
|
|
259
|
+
return {"accepted": True, "operation": "shutdown"}
|
|
260
|
+
if self._provider_control_handler is None:
|
|
261
|
+
raise ValueError("control operation is not supported")
|
|
262
|
+
return await self._provider_control_handler(request)
|
|
263
|
+
|
|
264
|
+
async def _validate_session_binding(
|
|
265
|
+
self,
|
|
266
|
+
session_id: str,
|
|
267
|
+
request: Mapping[str, object],
|
|
268
|
+
) -> None:
|
|
269
|
+
runtime_session_id = request.get("runtime_session_id")
|
|
270
|
+
session_capability = request.get("session_capability")
|
|
271
|
+
async with self.storage.transaction() as transaction:
|
|
272
|
+
bcn_session = await transaction.get_bcn_session(session_id)
|
|
273
|
+
if bcn_session is None:
|
|
274
|
+
raise CommandDispatchError(
|
|
275
|
+
"SESSION_NOT_FOUND",
|
|
276
|
+
f"unknown bcn session: {session_id}",
|
|
277
|
+
)
|
|
278
|
+
runtime_session = await transaction.find_runtime_session(session_id)
|
|
279
|
+
if runtime_session is None or runtime_session_id != (runtime_session.id):
|
|
280
|
+
raise CommandDispatchError(
|
|
281
|
+
"SESSION_BINDING_FAILED",
|
|
282
|
+
"runtime session binding is invalid",
|
|
283
|
+
)
|
|
284
|
+
expected_capability = self._session_capabilities.get(session_id)
|
|
285
|
+
if (
|
|
286
|
+
expected_capability is None
|
|
287
|
+
or not isinstance(session_capability, str)
|
|
288
|
+
or not hmac.compare_digest(
|
|
289
|
+
session_capability.encode(), expected_capability.encode()
|
|
290
|
+
)
|
|
291
|
+
):
|
|
292
|
+
raise CommandDispatchError(
|
|
293
|
+
"SESSION_BINDING_FAILED",
|
|
294
|
+
"session capability is invalid",
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
def _runtime_environment(self, session: RuntimeSession) -> Mapping[str, str]:
|
|
298
|
+
self._runtime_session_ids[session.bcn_session_id] = session.id
|
|
299
|
+
return self._build_command_environment(
|
|
300
|
+
session.bcn_session_id,
|
|
301
|
+
session.id,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def _build_command_environment(
|
|
305
|
+
self,
|
|
306
|
+
session_id: str,
|
|
307
|
+
runtime_session_id: str,
|
|
308
|
+
) -> dict[str, str]:
|
|
309
|
+
if not session_id:
|
|
310
|
+
raise ValueError("session_id must be a non-empty string")
|
|
311
|
+
if not runtime_session_id:
|
|
312
|
+
raise ValueError("runtime_session_id must be a non-empty string")
|
|
313
|
+
wrapper_path = self._wrapper_path
|
|
314
|
+
if wrapper_path is None:
|
|
315
|
+
raise RuntimeError("bcc wrapper is not installed")
|
|
316
|
+
session_capability = self._session_capabilities.setdefault(
|
|
317
|
+
session_id,
|
|
318
|
+
secrets.token_urlsafe(32),
|
|
319
|
+
)
|
|
320
|
+
wrapper_directory = str(wrapper_path.parent)
|
|
321
|
+
allowed = set(_PLATFORM_ENVIRONMENT)
|
|
322
|
+
for name in self.runtime.environment_variable_names():
|
|
323
|
+
if not _ENVIRONMENT_NAME.fullmatch(name):
|
|
324
|
+
raise ValueError(f"runtime environment name is invalid: {name}")
|
|
325
|
+
if name.startswith("BCN_") or name in _FORBIDDEN_ENVIRONMENT:
|
|
326
|
+
raise ValueError(f"runtime environment name is reserved: {name}")
|
|
327
|
+
allowed.add(name)
|
|
328
|
+
for name in self._runtime_environment_include:
|
|
329
|
+
if not _ENVIRONMENT_NAME.fullmatch(name):
|
|
330
|
+
raise ValueError(f"runtime environment name is invalid: {name}")
|
|
331
|
+
if name.startswith("BCN_") or name in _FORBIDDEN_ENVIRONMENT:
|
|
332
|
+
raise ValueError(f"runtime environment name is reserved: {name}")
|
|
333
|
+
if name not in os.environ:
|
|
334
|
+
raise ValueError(f"runtime environment variable is missing: {name}")
|
|
335
|
+
allowed.add(name)
|
|
336
|
+
environment = {
|
|
337
|
+
name: os.environ[name] for name in sorted(allowed) if os.environ.get(name)
|
|
338
|
+
}
|
|
339
|
+
environment["PATH"] = os.pathsep.join(
|
|
340
|
+
(wrapper_directory, environment.get("PATH", os.defpath))
|
|
341
|
+
)
|
|
342
|
+
environment.update(
|
|
343
|
+
{
|
|
344
|
+
"BCN_ENDPOINT": self.endpoint,
|
|
345
|
+
"BCN_SESSION_ID": session_id,
|
|
346
|
+
"BCN_RUNTIME_SESSION_ID": runtime_session_id,
|
|
347
|
+
"BCN_COMMAND_CAPABILITY": session_capability,
|
|
348
|
+
}
|
|
349
|
+
)
|
|
350
|
+
return environment
|
|
351
|
+
|
|
352
|
+
async def _run_runtime_command(
|
|
353
|
+
self,
|
|
354
|
+
session_id: str,
|
|
355
|
+
arguments: Sequence[str],
|
|
356
|
+
body: str | None,
|
|
357
|
+
) -> None:
|
|
358
|
+
if not session_id:
|
|
359
|
+
raise ValueError("session_id must be a non-empty string")
|
|
360
|
+
if not arguments or any(
|
|
361
|
+
not isinstance(argument, str) for argument in arguments
|
|
362
|
+
):
|
|
363
|
+
raise ValueError("runtime command arguments must be non-empty text")
|
|
364
|
+
wrapper_path = self._wrapper_path
|
|
365
|
+
if wrapper_path is None:
|
|
366
|
+
raise RuntimeError("bcc wrapper is not installed")
|
|
367
|
+
self.command_log.append((session_id, tuple(arguments)))
|
|
368
|
+
runtime_session_id = self._runtime_session_ids.get(
|
|
369
|
+
session_id,
|
|
370
|
+
f"runtime-{session_id}",
|
|
371
|
+
)
|
|
372
|
+
environment = self._build_command_environment(
|
|
373
|
+
session_id,
|
|
374
|
+
runtime_session_id,
|
|
375
|
+
)
|
|
376
|
+
process = await asyncio.create_subprocess_exec(
|
|
377
|
+
str(wrapper_path),
|
|
378
|
+
*arguments,
|
|
379
|
+
stdin=asyncio.subprocess.PIPE,
|
|
380
|
+
stdout=asyncio.subprocess.PIPE,
|
|
381
|
+
stderr=asyncio.subprocess.PIPE,
|
|
382
|
+
env=environment,
|
|
383
|
+
)
|
|
384
|
+
input_data = body.encode() if body is not None else None
|
|
385
|
+
try:
|
|
386
|
+
_stdout, stderr = await process.communicate(input=input_data)
|
|
387
|
+
except asyncio.CancelledError:
|
|
388
|
+
if process.returncode is None:
|
|
389
|
+
process.terminate()
|
|
390
|
+
await process.wait()
|
|
391
|
+
raise
|
|
392
|
+
if process.returncode != 0:
|
|
393
|
+
error = stderr.decode(errors="replace").strip()
|
|
394
|
+
command = " ".join(arguments)
|
|
395
|
+
raise RuntimeError(f"bcc command failed ({command}): {error}")
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
__all__ = ["CommandRecord", "NodeApplication"]
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import mimetypes
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
from collections.abc import AsyncIterable, Awaitable, Callable
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
from uuid import UUID, uuid7
|
|
11
|
+
|
|
12
|
+
from ..core.models import InboundAttachment
|
|
13
|
+
|
|
14
|
+
_SAFE_SUFFIX = re.compile(r"^\.[A-Za-z0-9]{1,10}$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AttachmentMaterializer:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
workspace: Callable[[], Path],
|
|
21
|
+
referenced_paths: Callable[[], Awaitable[set[str]]],
|
|
22
|
+
*,
|
|
23
|
+
max_file_bytes: int = 25 * 1024 * 1024,
|
|
24
|
+
max_workspace_bytes: int = 1024 * 1024 * 1024,
|
|
25
|
+
) -> None:
|
|
26
|
+
self._workspace = workspace
|
|
27
|
+
self._referenced_paths = referenced_paths
|
|
28
|
+
self._max_file_bytes = max_file_bytes
|
|
29
|
+
self._max_workspace_bytes = max_workspace_bytes
|
|
30
|
+
self._lock = asyncio.Lock()
|
|
31
|
+
|
|
32
|
+
async def reconcile(self) -> None:
|
|
33
|
+
root = self._workspace() / "attachments"
|
|
34
|
+
staging = root / ".staging"
|
|
35
|
+
await asyncio.to_thread(staging.mkdir, parents=True, exist_ok=True, mode=0o700)
|
|
36
|
+
for path in await asyncio.to_thread(lambda: tuple(staging.iterdir())):
|
|
37
|
+
if path.is_file() and not path.is_symlink():
|
|
38
|
+
await asyncio.to_thread(path.unlink)
|
|
39
|
+
referenced = await self._referenced_paths()
|
|
40
|
+
for path in await asyncio.to_thread(lambda: tuple(root.iterdir())):
|
|
41
|
+
if path == staging or not path.is_dir() or path.is_symlink():
|
|
42
|
+
continue
|
|
43
|
+
try:
|
|
44
|
+
if UUID(path.name).version != 7:
|
|
45
|
+
continue
|
|
46
|
+
except ValueError:
|
|
47
|
+
continue
|
|
48
|
+
prefix = str(PurePosixPath("attachments", path.name)) + "/"
|
|
49
|
+
if any(relative_path.startswith(prefix) for relative_path in referenced):
|
|
50
|
+
continue
|
|
51
|
+
await asyncio.to_thread(shutil.rmtree, path)
|
|
52
|
+
|
|
53
|
+
async def materialize(
|
|
54
|
+
self,
|
|
55
|
+
source: bytes | AsyncIterable[bytes],
|
|
56
|
+
*,
|
|
57
|
+
name: str,
|
|
58
|
+
kind: str,
|
|
59
|
+
media_type: str | None = None,
|
|
60
|
+
) -> InboundAttachment:
|
|
61
|
+
if not name or not kind:
|
|
62
|
+
raise ValueError("attachment name and kind must be non-empty")
|
|
63
|
+
attachment_id = str(uuid7())
|
|
64
|
+
suffix = Path(name).suffix
|
|
65
|
+
if not _SAFE_SUFFIX.fullmatch(suffix):
|
|
66
|
+
guessed = mimetypes.guess_extension(media_type or "") or ".bin"
|
|
67
|
+
suffix = guessed if _SAFE_SUFFIX.fullmatch(guessed) else ".bin"
|
|
68
|
+
relative = PurePosixPath(
|
|
69
|
+
"attachments", attachment_id, f"content{suffix.lower()}"
|
|
70
|
+
)
|
|
71
|
+
root = self._workspace() / "attachments"
|
|
72
|
+
staging = root / ".staging"
|
|
73
|
+
destination = self._workspace().joinpath(*relative.parts)
|
|
74
|
+
temporary = staging / f"{attachment_id}.part"
|
|
75
|
+
async with self._lock:
|
|
76
|
+
await asyncio.to_thread(
|
|
77
|
+
staging.mkdir, parents=True, exist_ok=True, mode=0o700
|
|
78
|
+
)
|
|
79
|
+
current_size = await asyncio.to_thread(self._stored_size, root)
|
|
80
|
+
size = 0
|
|
81
|
+
try:
|
|
82
|
+
with temporary.open("xb") as output:
|
|
83
|
+
if isinstance(source, bytes):
|
|
84
|
+
size = len(source)
|
|
85
|
+
if size > self._max_file_bytes:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
"attachment exceeds the per-file size limit"
|
|
88
|
+
)
|
|
89
|
+
if current_size + size > self._max_workspace_bytes:
|
|
90
|
+
raise ValueError("attachment workspace quota exceeded")
|
|
91
|
+
await asyncio.to_thread(output.write, source)
|
|
92
|
+
else:
|
|
93
|
+
async for chunk in source:
|
|
94
|
+
if not isinstance(chunk, bytes):
|
|
95
|
+
raise TypeError("attachment stream must yield bytes")
|
|
96
|
+
size += len(chunk)
|
|
97
|
+
if size > self._max_file_bytes:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
"attachment exceeds the per-file size limit"
|
|
100
|
+
)
|
|
101
|
+
if current_size + size > self._max_workspace_bytes:
|
|
102
|
+
raise ValueError("attachment workspace quota exceeded")
|
|
103
|
+
await asyncio.to_thread(output.write, chunk)
|
|
104
|
+
await asyncio.to_thread(output.flush)
|
|
105
|
+
await asyncio.to_thread(os.fsync, output.fileno())
|
|
106
|
+
await asyncio.to_thread(
|
|
107
|
+
destination.parent.mkdir, parents=True, mode=0o700
|
|
108
|
+
)
|
|
109
|
+
await asyncio.to_thread(os.replace, temporary, destination)
|
|
110
|
+
if os.name != "nt":
|
|
111
|
+
await asyncio.to_thread(destination.chmod, 0o600)
|
|
112
|
+
except BaseException:
|
|
113
|
+
if temporary.exists():
|
|
114
|
+
await asyncio.to_thread(temporary.unlink)
|
|
115
|
+
raise
|
|
116
|
+
return InboundAttachment(
|
|
117
|
+
attachment_id=attachment_id,
|
|
118
|
+
name=Path(name).name,
|
|
119
|
+
kind=kind,
|
|
120
|
+
state="ready",
|
|
121
|
+
media_type=media_type,
|
|
122
|
+
relative_path=str(relative),
|
|
123
|
+
size_bytes=size,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def failed(
|
|
127
|
+
self,
|
|
128
|
+
*,
|
|
129
|
+
name: str,
|
|
130
|
+
kind: str,
|
|
131
|
+
error: str,
|
|
132
|
+
media_type: str | None = None,
|
|
133
|
+
) -> InboundAttachment:
|
|
134
|
+
return InboundAttachment(
|
|
135
|
+
attachment_id=str(uuid7()),
|
|
136
|
+
name=Path(name).name or "attachment.bin",
|
|
137
|
+
kind=kind,
|
|
138
|
+
state="failed",
|
|
139
|
+
media_type=media_type,
|
|
140
|
+
error=error,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def _stored_size(root: Path) -> int:
|
|
145
|
+
if not root.exists():
|
|
146
|
+
return 0
|
|
147
|
+
return sum(
|
|
148
|
+
path.stat().st_size
|
|
149
|
+
for path in root.glob("*/content.*")
|
|
150
|
+
if path.is_file() and not path.is_symlink()
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
__all__ = ["AttachmentMaterializer"]
|