flode 0.60.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.
- flode/__init__.py +88 -0
- flode/analysis/__init__.py +37 -0
- flode/analysis/frequency_response.py +349 -0
- flode/analysis/linearize.py +714 -0
- flode/analysis/stability.py +229 -0
- flode/blocks/__init__.py +107 -0
- flode/blocks/_lti_utils.py +121 -0
- flode/blocks/cast.py +66 -0
- flode/blocks/continuous.py +474 -0
- flode/blocks/discontinuities.py +258 -0
- flode/blocks/discrete.py +612 -0
- flode/blocks/logic.py +120 -0
- flode/blocks/lookup.py +1025 -0
- flode/blocks/mathops.py +598 -0
- flode/blocks/pythonfunc.py +560 -0
- flode/blocks/pythonfunc_rewrite.py +1935 -0
- flode/blocks/pythonfunc_source.py +392 -0
- flode/blocks/random_source.py +179 -0
- flode/blocks/rounding.py +65 -0
- flode/blocks/routing.py +582 -0
- flode/blocks/sinks.py +309 -0
- flode/blocks/sources.py +215 -0
- flode/blocks/transport_delay.py +92 -0
- flode/blocks/userfunc.py +359 -0
- flode/core/__init__.py +5 -0
- flode/core/block.py +497 -0
- flode/core/decorator.py +1149 -0
- flode/core/dtypes.py +1026 -0
- flode/core/identifiers.py +157 -0
- flode/core/persistence.py +1319 -0
- flode/core/simulator.py +1690 -0
- flode/exceptions.py +226 -0
- flode/libraries/__init__.py +341 -0
- flode/libraries/_loader.py +302 -0
- flode/libraries/std.flwlib.json +347 -0
- flode/server/__init__.py +23 -0
- flode/server/app.py +185 -0
- flode/server/cli.py +555 -0
- flode/server/config.py +516 -0
- flode/server/errors.py +397 -0
- flode/server/library_registry.py +125 -0
- flode/server/migrations/__init__.py +80 -0
- flode/server/registry.py +950 -0
- flode/server/registry_translations.py +804 -0
- flode/server/routes/__init__.py +16 -0
- flode/server/routes/blocks.py +447 -0
- flode/server/routes/files.py +800 -0
- flode/server/routes/libraries.py +129 -0
- flode/server/routes/models.py +67 -0
- flode/server/routes/simulations.py +355 -0
- flode/server/runtime.py +354 -0
- flode/server/security/__init__.py +7 -0
- flode/server/security/origin.py +122 -0
- flode/server/security/paths.py +143 -0
- flode/server/settings.py +42 -0
- flode/server/static/.app-version +1 -0
- flode/server/static/assets/index-BnL2nNOe.css +1 -0
- flode/server/static/assets/index-DMWZBAA_.js +100 -0
- flode/server/static/assets/index-DMWZBAA_.js.map +1 -0
- flode/server/static/favicon.ico +0 -0
- flode/server/static/favicon.svg +9 -0
- flode/server/static/index.html +15 -0
- flode/subsystems/__init__.py +20 -0
- flode/subsystems/_mask.py +154 -0
- flode/subsystems/control_blocks.py +245 -0
- flode/subsystems/ports.py +146 -0
- flode/subsystems/subsystem.py +1173 -0
- flode-0.60.3.dist-info/METADATA +91 -0
- flode-0.60.3.dist-info/RECORD +73 -0
- flode-0.60.3.dist-info/WHEEL +5 -0
- flode-0.60.3.dist-info/entry_points.txt +2 -0
- flode-0.60.3.dist-info/licenses/LICENSE +21 -0
- flode-0.60.3.dist-info/top_level.txt +1 -0
flode/server/runtime.py
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""シミュレーション実行マネージャ (ADR-0011 §(4))。
|
|
2
|
+
|
|
3
|
+
``ThreadPoolExecutor`` で ``Simulator.run()`` を背景実行し、Scope データを
|
|
4
|
+
``asyncio.Queue`` に push して WebSocket クライアントへ配信する。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import dataclasses
|
|
11
|
+
import logging
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from collections.abc import AsyncIterator
|
|
16
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from ..core.persistence import serialize_t_end
|
|
22
|
+
from ..core.simulator import Simulator
|
|
23
|
+
from ..exceptions import FlodeError, SimulationStillRunningError
|
|
24
|
+
from .errors import build_failure_payload
|
|
25
|
+
|
|
26
|
+
_logger = logging.getLogger("flode.server.runtime")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclasses.dataclass
|
|
30
|
+
class SimulationState:
|
|
31
|
+
"""シミュレーションの公開状態 (REST ``GET /simulations/{id}`` 用)。
|
|
32
|
+
|
|
33
|
+
ADR-0042 §論点 3-A: ``t_end`` は ``float`` (有限値) または ``"inf"`` 文字列
|
|
34
|
+
リテラル (= unbounded、``Stop Time = inf``) を取る Union。
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
simulation_id: str
|
|
38
|
+
model_id: str
|
|
39
|
+
status: str # "running" / "completed" / "failed" / "stopped"
|
|
40
|
+
current_t: float
|
|
41
|
+
t_end: float | str
|
|
42
|
+
started_at: float
|
|
43
|
+
finished_at: float | None = None
|
|
44
|
+
error: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _SimulationRecord:
|
|
48
|
+
"""内部追跡: Simulator インスタンス + 背景タスク + asyncio キュー。"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
*,
|
|
53
|
+
simulation_id: str,
|
|
54
|
+
model_id: str,
|
|
55
|
+
simulator: Simulator,
|
|
56
|
+
loop: asyncio.AbstractEventLoop,
|
|
57
|
+
scope_batch_size: int,
|
|
58
|
+
) -> None:
|
|
59
|
+
self.simulation_id = simulation_id
|
|
60
|
+
self.model_id = model_id
|
|
61
|
+
self.simulator = simulator
|
|
62
|
+
self.loop = loop
|
|
63
|
+
self.scope_batch_size = scope_batch_size
|
|
64
|
+
self.queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=1024)
|
|
65
|
+
self.state = SimulationState(
|
|
66
|
+
simulation_id=simulation_id,
|
|
67
|
+
model_id=model_id,
|
|
68
|
+
status="running",
|
|
69
|
+
current_t=0.0,
|
|
70
|
+
# ADR-0042 §論点 3-A: ``math.inf`` は wire 上 ``"inf"`` 文字列で
|
|
71
|
+
# 配信する (= JS 側 JSON.stringify(Infinity)==="null" の罠回避)。
|
|
72
|
+
t_end=serialize_t_end(simulator.t_end),
|
|
73
|
+
started_at=time.time(),
|
|
74
|
+
)
|
|
75
|
+
self.future: Future[None] | None = None
|
|
76
|
+
# 各 Scope の前回送信済みインデックス (バッチ送信用)
|
|
77
|
+
self._scope_cursors: dict[str, int] = {}
|
|
78
|
+
# ADR-0056 §B-4: WS 再接続時の terminal メッセージ replay 用。
|
|
79
|
+
# ``_run_in_thread`` の finally で最終 ``completed`` / ``stopped`` / ``failed``
|
|
80
|
+
# メッセージをここに保持し、``SimulationManager.stream`` が再接続時に冒頭で 1
|
|
81
|
+
# 回 yield してから queue 処理に入る。
|
|
82
|
+
self.last_terminal_msg: dict[str, Any] | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SimulationManager:
|
|
86
|
+
"""背景タスクで ``Simulator.run`` を回し WS にイベントを配信するマネージャ。
|
|
87
|
+
|
|
88
|
+
``FastAPI`` の lifespan 内で 1 つだけ生成し、リクエストハンドラから利用する。
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self, max_concurrent: int = 4) -> None:
|
|
92
|
+
self._executor = ThreadPoolExecutor(max_workers=max_concurrent)
|
|
93
|
+
self._records: dict[str, _SimulationRecord] = {}
|
|
94
|
+
self._lock = threading.Lock()
|
|
95
|
+
|
|
96
|
+
def shutdown(self) -> None:
|
|
97
|
+
self._executor.shutdown(wait=False, cancel_futures=True)
|
|
98
|
+
|
|
99
|
+
def list_simulations(self) -> list[SimulationState]:
|
|
100
|
+
with self._lock:
|
|
101
|
+
return [dataclasses.replace(r.state) for r in self._records.values()]
|
|
102
|
+
|
|
103
|
+
def get_state(self, sim_id: str) -> SimulationState:
|
|
104
|
+
with self._lock:
|
|
105
|
+
if sim_id not in self._records:
|
|
106
|
+
raise FlodeError(f"Unknown simulation_id: {sim_id!r}")
|
|
107
|
+
return dataclasses.replace(self._records[sim_id].state)
|
|
108
|
+
|
|
109
|
+
def stop(self, sim_id: str) -> None:
|
|
110
|
+
with self._lock:
|
|
111
|
+
if sim_id not in self._records:
|
|
112
|
+
raise FlodeError(f"Unknown simulation_id: {sim_id!r}")
|
|
113
|
+
rec = self._records[sim_id]
|
|
114
|
+
rec.simulator.request_stop()
|
|
115
|
+
|
|
116
|
+
def start(
|
|
117
|
+
self,
|
|
118
|
+
*,
|
|
119
|
+
model_id: str,
|
|
120
|
+
simulator: Simulator,
|
|
121
|
+
loop: asyncio.AbstractEventLoop,
|
|
122
|
+
scope_batch_size: int,
|
|
123
|
+
) -> str:
|
|
124
|
+
"""既に構築済の ``Simulator`` インスタンスからシミュレーションを開始する。
|
|
125
|
+
|
|
126
|
+
ADR-0041 §論点 5-A 以前は ``model_path`` を受け取って ``Simulator.load``
|
|
127
|
+
を内部で呼んでいたが、ファイル ロード / インラインモデル / 旧 ``model_id``
|
|
128
|
+
の 3 形式が混在するようになったため **ロード責務を呼び出し側に委譲**
|
|
129
|
+
した。route handler が文字列引数を Simulator に解決してから start を
|
|
130
|
+
呼ぶ形に統一。
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
model_id: 表示用モデル識別子 (= ファイル path / 旧 model_id /
|
|
134
|
+
``"<inline>"`` のいずれか)。トラッキング表示用で fs にアクセス
|
|
135
|
+
しない。
|
|
136
|
+
simulator: 構築済の ``Simulator`` インスタンス (= ``Simulator.load`` /
|
|
137
|
+
``Simulator.from_dict`` のどちらかで生成済)。
|
|
138
|
+
loop: WS dispatch 用の asyncio loop。
|
|
139
|
+
scope_batch_size: Scope バッチ送信のサイズ。
|
|
140
|
+
"""
|
|
141
|
+
sim_id = uuid.uuid4().hex[:12]
|
|
142
|
+
record = _SimulationRecord(
|
|
143
|
+
simulation_id=sim_id,
|
|
144
|
+
model_id=model_id,
|
|
145
|
+
simulator=simulator,
|
|
146
|
+
loop=loop,
|
|
147
|
+
scope_batch_size=scope_batch_size,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# on_step_callback を設定 (バッチ送信トリガー)
|
|
151
|
+
simulator.on_step_callback = lambda t, t_end: self._on_step(record, t, t_end)
|
|
152
|
+
|
|
153
|
+
with self._lock:
|
|
154
|
+
self._records[sim_id] = record
|
|
155
|
+
record.future = self._executor.submit(self._run_in_thread, record)
|
|
156
|
+
return sim_id
|
|
157
|
+
|
|
158
|
+
# ---------- 内部実装 ----------
|
|
159
|
+
|
|
160
|
+
def _on_step(self, rec: _SimulationRecord, t: float, t_end: float) -> bool:
|
|
161
|
+
rec.state.current_t = t
|
|
162
|
+
# 各 Scope について新規サンプル数が batch_size に達したら送信
|
|
163
|
+
for b in rec.simulator.blocks:
|
|
164
|
+
scope = _as_scope(b)
|
|
165
|
+
if scope is None:
|
|
166
|
+
continue
|
|
167
|
+
assert scope.id is not None
|
|
168
|
+
cursor = rec._scope_cursors.get(scope.id, 0)
|
|
169
|
+
n_total = len(scope.times)
|
|
170
|
+
if n_total - cursor >= rec.scope_batch_size:
|
|
171
|
+
self._dispatch_scope_batch(rec, scope, cursor, n_total)
|
|
172
|
+
rec._scope_cursors[scope.id] = n_total
|
|
173
|
+
# progress イベント (バッチサイズに関わらず毎ステップ送る)。
|
|
174
|
+
# ADR-0042 §論点 3-A: ``t_end`` は ``serialize_t_end`` 経由で
|
|
175
|
+
# ``math.inf → "inf"`` 文字列にする (= wire 形式統一)。
|
|
176
|
+
self._dispatch(
|
|
177
|
+
rec,
|
|
178
|
+
{
|
|
179
|
+
"type": "progress",
|
|
180
|
+
"current_t": t,
|
|
181
|
+
"t_end": serialize_t_end(t_end),
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
def _flush_remaining_scopes(self, rec: _SimulationRecord) -> None:
|
|
187
|
+
"""終了時に残った scope サンプルを送る。"""
|
|
188
|
+
for b in rec.simulator.blocks:
|
|
189
|
+
scope = _as_scope(b)
|
|
190
|
+
if scope is None:
|
|
191
|
+
continue
|
|
192
|
+
assert scope.id is not None
|
|
193
|
+
cursor = rec._scope_cursors.get(scope.id, 0)
|
|
194
|
+
n_total = len(scope.times)
|
|
195
|
+
if n_total > cursor:
|
|
196
|
+
self._dispatch_scope_batch(rec, scope, cursor, n_total)
|
|
197
|
+
rec._scope_cursors[scope.id] = n_total
|
|
198
|
+
|
|
199
|
+
def _dispatch_scope_batch(
|
|
200
|
+
self, rec: _SimulationRecord, scope: Any, start: int, end: int
|
|
201
|
+
) -> None:
|
|
202
|
+
# ADR-0042 §論点 2-A: ``Scope.buffer_mode='ring'`` で ``scope.times`` が
|
|
203
|
+
# ``deque`` の場合、slice 直接適用は非対応。一度 list / np.asarray に
|
|
204
|
+
# 変換してから slice することで両モード対応する。
|
|
205
|
+
# NOTE: ring wrap 後に最古サンプルが drop されると ``cursor`` が
|
|
206
|
+
# stale になる edge case があるが、Step 5 (WS scope_overflow message
|
|
207
|
+
# 配信) で本格対応する。本ステップでは finite t_end 既存挙動の
|
|
208
|
+
# 完全互換のみを保証する。
|
|
209
|
+
times = list(scope.times)[start:end]
|
|
210
|
+
values_arr = np.asarray(scope.values)[start:end]
|
|
211
|
+
msg = {
|
|
212
|
+
"type": "scope_batch",
|
|
213
|
+
"scope_id": scope.id,
|
|
214
|
+
"times": times,
|
|
215
|
+
"values": values_arr.tolist(),
|
|
216
|
+
}
|
|
217
|
+
self._dispatch(rec, msg)
|
|
218
|
+
|
|
219
|
+
def _dispatch(self, rec: _SimulationRecord, msg: dict[str, Any]) -> None:
|
|
220
|
+
"""thread から asyncio.Queue へ thread-safe に push (ADR-0011 Risk #1)。
|
|
221
|
+
|
|
222
|
+
Queue が満杯 (= 1024 件) の場合は **古いメッセージを drop** して新しい
|
|
223
|
+
メッセージを優先する (back-pressure ではなく fail-fast)。これにより thread
|
|
224
|
+
側が put で永続的に block するのを防ぐ (code-reviewer MUST #4 修正)。
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
async def _put_or_drop() -> None:
|
|
228
|
+
try:
|
|
229
|
+
rec.queue.put_nowait(msg)
|
|
230
|
+
except asyncio.QueueFull:
|
|
231
|
+
# 古いものを 1 件 drop して空きを作って入れ直す
|
|
232
|
+
try:
|
|
233
|
+
_ = rec.queue.get_nowait()
|
|
234
|
+
except asyncio.QueueEmpty:
|
|
235
|
+
pass
|
|
236
|
+
try:
|
|
237
|
+
rec.queue.put_nowait(msg)
|
|
238
|
+
except asyncio.QueueFull:
|
|
239
|
+
_logger.warning(
|
|
240
|
+
"WS queue still full after eviction; dropping message type=%r "
|
|
241
|
+
"(simulation_id=%s)",
|
|
242
|
+
msg.get("type"),
|
|
243
|
+
rec.simulation_id,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
asyncio.run_coroutine_threadsafe(_put_or_drop(), rec.loop)
|
|
248
|
+
except RuntimeError:
|
|
249
|
+
# loop が閉じている場合は drop (シャットダウン途中)
|
|
250
|
+
_logger.debug("event loop closed; dropping message %r", msg.get("type"))
|
|
251
|
+
|
|
252
|
+
def _run_in_thread(self, rec: _SimulationRecord) -> None:
|
|
253
|
+
# ADR-0056 §B-3: 失敗時の構造化 payload は finally 内で `failed` 1 件にまとめる
|
|
254
|
+
# (= 旧 `error` メッセージの事前送出を廃止)。例外捕捉時は payload を保持する
|
|
255
|
+
# だけ、実際の送出は finally で実施する。
|
|
256
|
+
failure_payload: dict[str, Any] | None = None
|
|
257
|
+
try:
|
|
258
|
+
rec.simulator.run()
|
|
259
|
+
self._flush_remaining_scopes(rec)
|
|
260
|
+
if rec.simulator.is_stopped:
|
|
261
|
+
rec.state.status = "stopped"
|
|
262
|
+
else:
|
|
263
|
+
rec.state.status = "completed"
|
|
264
|
+
except Exception as e: # noqa: BLE001 - server境界でドメイン例外もシステム例外も補足
|
|
265
|
+
_logger.exception("simulation %s failed", rec.simulation_id)
|
|
266
|
+
rec.state.status = "failed"
|
|
267
|
+
rec.state.error = f"{type(e).__name__}: {e}"
|
|
268
|
+
failure_payload = build_failure_payload(
|
|
269
|
+
e,
|
|
270
|
+
simulator=rec.simulator,
|
|
271
|
+
t=rec.state.current_t,
|
|
272
|
+
)
|
|
273
|
+
finally:
|
|
274
|
+
rec.state.finished_at = time.time()
|
|
275
|
+
duration = rec.state.finished_at - rec.state.started_at
|
|
276
|
+
terminal: dict[str, Any] = {
|
|
277
|
+
"type": rec.state.status, # completed / stopped / failed
|
|
278
|
+
"duration_sec": duration,
|
|
279
|
+
}
|
|
280
|
+
if failure_payload is not None:
|
|
281
|
+
# ADR-0056 §B-1: 失敗時のみ構造化フィールドをマージ。
|
|
282
|
+
terminal.update(failure_payload)
|
|
283
|
+
# ADR-0056 §B-4: replay 用に保持 (= 再接続クライアントが冒頭で受信できる)。
|
|
284
|
+
# code-reviewer MUST-2: dispatch を先に行ってから last_terminal_msg をセット
|
|
285
|
+
# することで、stream() の replay 判定 (last_terminal_msg is not None and
|
|
286
|
+
# queue.empty()) が「dispatch 前 / queue 未投入」窓で誤発火するレースを
|
|
287
|
+
# 防ぐ (= dispatch 後は queue に入っているので queue.empty() が False、
|
|
288
|
+
# 通常 drain ルートへ確実に流れる)。
|
|
289
|
+
self._dispatch(rec, terminal)
|
|
290
|
+
rec.last_terminal_msg = terminal
|
|
291
|
+
|
|
292
|
+
async def stream(self, sim_id: str) -> AsyncIterator[dict[str, Any]]:
|
|
293
|
+
with self._lock:
|
|
294
|
+
if sim_id not in self._records:
|
|
295
|
+
raise FlodeError(f"Unknown simulation_id: {sim_id!r}")
|
|
296
|
+
rec = self._records[sim_id]
|
|
297
|
+
# ADR-0056 §B-4: 終端済みシミュレーションへの **再接続** (= queue 既消費)
|
|
298
|
+
# 時、保存済みの terminal を 1 回 yield して即クローズ。失敗詳細を
|
|
299
|
+
# 取り損ねないことが目的。初回接続で queue にメッセージが残っている
|
|
300
|
+
# ケースは下の通常ループに任せる (= progress / scope_batch / 最後に
|
|
301
|
+
# terminal を順に yield)。
|
|
302
|
+
if rec.last_terminal_msg is not None and rec.queue.empty():
|
|
303
|
+
yield dict(rec.last_terminal_msg)
|
|
304
|
+
return
|
|
305
|
+
terminal_types = {"completed", "stopped", "failed"}
|
|
306
|
+
while True:
|
|
307
|
+
msg = await rec.queue.get()
|
|
308
|
+
yield msg
|
|
309
|
+
if msg.get("type") in terminal_types:
|
|
310
|
+
break
|
|
311
|
+
|
|
312
|
+
def get_results(self, sim_id: str) -> dict[str, Any]:
|
|
313
|
+
"""完了後に Scope データを一括取得 (ADR-0011 §(1) GET /results)。"""
|
|
314
|
+
with self._lock:
|
|
315
|
+
if sim_id not in self._records:
|
|
316
|
+
raise FlodeError(f"Unknown simulation_id: {sim_id!r}")
|
|
317
|
+
rec = self._records[sim_id]
|
|
318
|
+
if rec.state.status == "running":
|
|
319
|
+
raise SimulationStillRunningError(
|
|
320
|
+
f"Simulation {sim_id!r} is still running; results not yet available"
|
|
321
|
+
)
|
|
322
|
+
scopes: dict[str, Any] = {}
|
|
323
|
+
for b in rec.simulator.blocks:
|
|
324
|
+
scope = _as_scope(b)
|
|
325
|
+
if scope is None:
|
|
326
|
+
continue
|
|
327
|
+
assert scope.id is not None
|
|
328
|
+
scopes[scope.id] = {
|
|
329
|
+
"labels": list(scope.labels),
|
|
330
|
+
"times": list(scope.times),
|
|
331
|
+
"values": np.asarray(scope.values).tolist(),
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
"simulation_id": sim_id,
|
|
335
|
+
"status": rec.state.status,
|
|
336
|
+
"scopes": scopes,
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _as_scope(b: Any) -> Any:
|
|
341
|
+
"""``Scope`` 互換のブロックなら ``b`` を返し、そうでなければ ``None``。
|
|
342
|
+
|
|
343
|
+
duck-typing で ``record`` / ``times`` / ``values`` / ``labels`` を持つかを確認
|
|
344
|
+
する。``Block`` 基底に直接の attr 宣言がないため、mypy は ``Any`` 経由で呼び
|
|
345
|
+
出す形にする。
|
|
346
|
+
"""
|
|
347
|
+
if (
|
|
348
|
+
hasattr(b, "record")
|
|
349
|
+
and hasattr(b, "times")
|
|
350
|
+
and hasattr(b, "values")
|
|
351
|
+
and hasattr(b, "labels")
|
|
352
|
+
):
|
|
353
|
+
return b
|
|
354
|
+
return None
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""ブラウザ由来の cross-site 書き込み要求 (CSRF) を拒否する request guard (SPEC-0023 追補)。
|
|
2
|
+
|
|
3
|
+
背景 (security-reviewer 指摘、v0.49.0): flode は loopback bind を「ローカルの信頼できる
|
|
4
|
+
利用者しか到達しない」前提で無認証だが、**ブラウザ** はその前提の外にいる。利用者が
|
|
5
|
+
任意のサイトを閲覧しているだけで、そのページの JS は
|
|
6
|
+
``fetch("http://127.0.0.1:8770/api/v1/simulations", {method: "POST", mode: "no-cors",
|
|
7
|
+
headers: {"Content-Type": "text/plain"}, body: ...})`` (= preflight の要らない
|
|
8
|
+
*simple request*) を送れる。``PythonFunction`` 導入後はこれが任意コード実行に直結する。
|
|
9
|
+
|
|
10
|
+
防御 (state-changing = ``POST`` / ``PUT`` / ``PATCH`` / ``DELETE`` にのみ適用):
|
|
11
|
+
|
|
12
|
+
1. **Content-Type 強制**: 本文を持つ要求は ``application/json`` 以外を 415 で拒否する。
|
|
13
|
+
これで simple request が成立しなくなり、cross-origin 送信には preflight が必須になる
|
|
14
|
+
(CORS ミドルウェアが ``allow_origins`` 未設定なら preflight は失敗する)
|
|
15
|
+
2. **Origin 検証**: ``Origin`` ヘッダがある要求は、(a) ``settings.allow_origins`` に
|
|
16
|
+
含まれる、または (b) 要求の ``Host`` と同一オリジンで **かつ** そのホストが IP
|
|
17
|
+
リテラル / ``localhost`` (= DNS rebinding で偽装できない名前) のときだけ通す。
|
|
18
|
+
``Origin: null`` (sandbox iframe / file://) は拒否。``Origin`` が無い要求
|
|
19
|
+
(= curl 等の非ブラウザクライアント) はそのまま通す — ブラウザは書き込み要求に
|
|
20
|
+
必ず ``Origin`` を付けるので、無いものはブラウザ由来ではない
|
|
21
|
+
|
|
22
|
+
ホスト名 (``http://mypc.local:8770`` 等) で自サーバーにアクセスする運用は
|
|
23
|
+
``--allow-origin http://mypc.local:8770`` で明示的に許可する。
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import ipaddress
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from urllib.parse import urlsplit
|
|
31
|
+
|
|
32
|
+
STATE_CHANGING_METHODS: frozenset[str] = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
|
33
|
+
JSON_MEDIA_TYPE = "application/json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class RequestRejection:
|
|
38
|
+
"""guard が要求を拒否する理由 (HTTP status + 人間可読メッセージ)。"""
|
|
39
|
+
|
|
40
|
+
status_code: int
|
|
41
|
+
detail: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _hostname_of(host_with_port: str) -> str:
|
|
45
|
+
"""``Host`` / origin の ``host[:port]`` からホスト名部分 (小文字、``[]`` 除去) を返す。"""
|
|
46
|
+
h = host_with_port.strip().lower()
|
|
47
|
+
if h.startswith("["):
|
|
48
|
+
end = h.find("]")
|
|
49
|
+
return h[1:end] if end > 0 else h
|
|
50
|
+
if h.count(":") == 1:
|
|
51
|
+
return h.rsplit(":", 1)[0]
|
|
52
|
+
return h
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_unspoofable_host(hostname: str) -> bool:
|
|
56
|
+
"""DNS rebinding で偽装できないホスト名か (= IP リテラルまたは ``localhost``)。"""
|
|
57
|
+
name = hostname.strip().lower().strip("[]")
|
|
58
|
+
if name == "localhost":
|
|
59
|
+
return True
|
|
60
|
+
try:
|
|
61
|
+
ipaddress.ip_address(name)
|
|
62
|
+
except ValueError:
|
|
63
|
+
return False
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _media_type(content_type: str | None) -> str:
|
|
68
|
+
if not content_type:
|
|
69
|
+
return ""
|
|
70
|
+
return content_type.split(";", 1)[0].strip().lower()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def check_state_changing_request(
|
|
74
|
+
*,
|
|
75
|
+
method: str,
|
|
76
|
+
origin: str | None,
|
|
77
|
+
host: str | None,
|
|
78
|
+
content_type: str | None,
|
|
79
|
+
has_body: bool,
|
|
80
|
+
allow_origins: list[str] | tuple[str, ...],
|
|
81
|
+
) -> RequestRejection | None:
|
|
82
|
+
"""書き込み要求を通してよいか判定する純粋関数。``None`` = 許可。
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
method: HTTP メソッド。
|
|
86
|
+
origin: ``Origin`` ヘッダ (無ければ ``None``)。
|
|
87
|
+
host: ``Host`` ヘッダ (無ければ ``None``)。
|
|
88
|
+
content_type: ``Content-Type`` ヘッダ。
|
|
89
|
+
has_body: 本文があるか (``Content-Length > 0`` または ``Transfer-Encoding`` あり)。
|
|
90
|
+
allow_origins: ``settings.allow_origins`` (CORS 許可オリジンと共用)。
|
|
91
|
+
"""
|
|
92
|
+
if method.upper() not in STATE_CHANGING_METHODS:
|
|
93
|
+
return None
|
|
94
|
+
if has_body and _media_type(content_type) != JSON_MEDIA_TYPE:
|
|
95
|
+
return RequestRejection(
|
|
96
|
+
415,
|
|
97
|
+
f"State-changing requests must use Content-Type: {JSON_MEDIA_TYPE} "
|
|
98
|
+
f"(got {content_type!r}). This blocks cross-site form/simple requests.",
|
|
99
|
+
)
|
|
100
|
+
if origin is None:
|
|
101
|
+
return None
|
|
102
|
+
origin_norm = origin.strip().lower()
|
|
103
|
+
if origin_norm in {o.strip().lower().rstrip("/") for o in allow_origins}:
|
|
104
|
+
return None
|
|
105
|
+
if origin_norm == "null":
|
|
106
|
+
return RequestRejection(403, "Requests from an opaque (null) origin are rejected.")
|
|
107
|
+
parts = urlsplit(origin_norm)
|
|
108
|
+
if parts.scheme not in ("http", "https") or not parts.netloc:
|
|
109
|
+
return RequestRejection(403, f"Malformed Origin header: {origin!r}.")
|
|
110
|
+
if host is not None and parts.netloc == host.strip().lower():
|
|
111
|
+
if is_unspoofable_host(_hostname_of(parts.netloc)):
|
|
112
|
+
return None
|
|
113
|
+
return RequestRejection(
|
|
114
|
+
403,
|
|
115
|
+
f"Same-origin request via hostname {parts.netloc!r} is rejected to prevent DNS "
|
|
116
|
+
f"rebinding; add it explicitly with --allow-origin {origin}.",
|
|
117
|
+
)
|
|
118
|
+
return RequestRejection(
|
|
119
|
+
403,
|
|
120
|
+
f"Cross-origin request from {origin!r} is rejected. Add it with --allow-origin "
|
|
121
|
+
f"if this is your own frontend.",
|
|
122
|
+
)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Workspace path resolution with traversal defense (ADR-0041 §論点 2-A)。
|
|
2
|
+
|
|
3
|
+
REST ``/api/v1/files/*`` で利用者が渡す ``?path=...`` を **workspace root 配下**
|
|
4
|
+
に解決し、root 外への traversal や OS 固有の罠 (= Windows reserved name、
|
|
5
|
+
trailing space/dot、UNC、null byte) を一括検証する。
|
|
6
|
+
|
|
7
|
+
セキュリティ上の責務は本モジュールに集約する (= File API endpoint 各位置で
|
|
8
|
+
個別に検証しない、SSOT 原則)。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from urllib.parse import unquote
|
|
16
|
+
|
|
17
|
+
from ...exceptions import PathTraversalError
|
|
18
|
+
|
|
19
|
+
_CONTROL_CHAR_RE = re.compile(r"[\x00-\x1f]")
|
|
20
|
+
"""null byte 含む ASCII control char (\\x00-\\x1f)。
|
|
21
|
+
|
|
22
|
+
path injection 防御のため、URL decode 後の文字列にこれが残っていれば拒否。
|
|
23
|
+
``\\t`` ``\\n`` ``\\r`` も含まれる (= path として正当な用途がない)。
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
_WIN_DRIVE_RE = re.compile(r"^[A-Za-z]:")
|
|
27
|
+
"""Windows ドライブ指定 (= ``C:`` や ``c:``) の検出。"""
|
|
28
|
+
|
|
29
|
+
_WIN_RESERVED_NAMES = frozenset(
|
|
30
|
+
{
|
|
31
|
+
"con",
|
|
32
|
+
"prn",
|
|
33
|
+
"aux",
|
|
34
|
+
"nul",
|
|
35
|
+
*(f"com{i}" for i in range(0, 10)),
|
|
36
|
+
*(f"lpt{i}" for i in range(0, 10)),
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
"""Windows 予約デバイス名 (case-insensitive で判定)。
|
|
40
|
+
|
|
41
|
+
Microsoft 公式仕様 (= "Naming a File"、CON / PRN / AUX / NUL / COM0-COM9 /
|
|
42
|
+
LPT0-LPT9) に準拠。Linux / macOS でも cross-platform portability を維持
|
|
43
|
+
するため拒否。``CON.flw.json`` のような拡張子付きも、basename 部分が
|
|
44
|
+
予約名なら全 OS で拒否される (= Windows ではこれらは reserved file として
|
|
45
|
+
アクセス不能)。``COM10`` 以降は予約名ではない。
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resolve_workspace_path(workspace_root: Path, raw: str) -> Path:
|
|
50
|
+
"""``raw`` (URL encoded relative path) を ``workspace_root`` 配下に解決する。
|
|
51
|
+
|
|
52
|
+
呼び出し側との契約:
|
|
53
|
+
* ``raw`` は **単段 URL encoded** path を想定 (= REST query param
|
|
54
|
+
``?path=`` から取り出した文字列)。本関数で ``urllib.parse.unquote`` を
|
|
55
|
+
1 回適用する。
|
|
56
|
+
* **二重 encoded** (例: ``%252e%252e``) は意図的に decode せず、リテラル
|
|
57
|
+
``%2e`` として扱う (= reverse proxy 等で 2 段 decode される構成は
|
|
58
|
+
File API 実装側の責務として扱わない)。
|
|
59
|
+
* FastAPI ``Request.query_params["path"]`` は既に 1 段 decode 済の値を
|
|
60
|
+
返すため、Starlette の挙動 (= ``%2F`` を ``/`` に展開する) と本関数の
|
|
61
|
+
unquote が二重適用される懸念は **冪等の範囲内** (= リテラル ``%2F`` を
|
|
62
|
+
含まない通常 path では影響なし)。File API 実装時にフレームワークの
|
|
63
|
+
decode 挙動を確認すること。
|
|
64
|
+
* 本関数は I/O を行わない (= TOCTOU race の対象外)。File API 側で
|
|
65
|
+
``resolve_workspace_path`` 通過後の path に対して ``open`` / ``stat``
|
|
66
|
+
する間の symlink 差し替えは別途扱う。
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
workspace_root: ``--workspace=PATH`` で指定された絶対 path。本関数内部で
|
|
70
|
+
``resolve(strict=False)`` する。
|
|
71
|
+
raw: 単段 URL encoded 相対 path (POSIX 形式、``/`` 区切り限定)。
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
絶対 ``Path``。存在しない path も resolve 可能 (= ``strict=False``)。
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
PathTraversalError: 以下のいずれか:
|
|
78
|
+
* null byte / ASCII control char を含む
|
|
79
|
+
* backslash (``\\``) を含む (POSIX 形式限定の API)
|
|
80
|
+
* 絶対 path (``/`` 始まり) または Windows ドライブ (``C:`` 等)
|
|
81
|
+
* 単体 ``..`` (= root の親に出る、resolve 前の fail-fast)
|
|
82
|
+
* Windows 予約名 (CON / PRN / AUX / NUL / COM0-9 / LPT0-9、
|
|
83
|
+
case-insensitive、拡張子付き含む)
|
|
84
|
+
* 各セグメントが trailing space / dot で終わる (Windows トリミング
|
|
85
|
+
挙動回避)
|
|
86
|
+
* resolve 後 ``workspace_root`` 配下から外れる
|
|
87
|
+
|
|
88
|
+
File API 側で 4xx response を返す際は **エラーメッセージに利用者の
|
|
89
|
+
decoded 入力を含めない** こと (log injection / 情報漏洩の多層防御)。
|
|
90
|
+
"""
|
|
91
|
+
decoded = unquote(raw)
|
|
92
|
+
|
|
93
|
+
# Step 1: null byte / control char 拒否
|
|
94
|
+
# backslash 検査より先に置く理由 — control char は injection の最も危険な
|
|
95
|
+
# ベクタで、後続のメッセージや FS 操作に到達する前に最初に弾く。
|
|
96
|
+
if _CONTROL_CHAR_RE.search(decoded):
|
|
97
|
+
raise PathTraversalError("Path contains control characters or null byte")
|
|
98
|
+
|
|
99
|
+
# Step 2: backslash 拒否 (POSIX path 形式限定の API)
|
|
100
|
+
# UNC ``\\server\share`` や Windows-style ``\Windows\System32`` を同時に弾く。
|
|
101
|
+
if "\\" in decoded:
|
|
102
|
+
raise PathTraversalError("Backslash not allowed in workspace path; use forward slash")
|
|
103
|
+
|
|
104
|
+
# Step 3: 絶対 path / Windows ドライブ拒否
|
|
105
|
+
if decoded.startswith("/"):
|
|
106
|
+
raise PathTraversalError("Absolute path not allowed")
|
|
107
|
+
if _WIN_DRIVE_RE.match(decoded):
|
|
108
|
+
raise PathTraversalError("Drive-qualified path not allowed")
|
|
109
|
+
|
|
110
|
+
# Step 4: 単体 ``..`` の fail-fast 拒否
|
|
111
|
+
# 安全性は Step 6/7 (resolve + containment check) で十分担保されているが、
|
|
112
|
+
# ``raw == ".."`` の典型ケースで早期に明確なエラーメッセージを返すための
|
|
113
|
+
# 保険。``.strip()`` で先頭/末尾の whitespace のみ除去 (= ``" .. "`` も
|
|
114
|
+
# ここで拾う)。``foo/..`` のような compound は Step 6/7 で扱う。
|
|
115
|
+
if decoded.strip() == "..":
|
|
116
|
+
raise PathTraversalError("Parent escape not allowed")
|
|
117
|
+
|
|
118
|
+
# Step 5: 各セグメント検証 (Windows 予約名 + trailing space/dot)
|
|
119
|
+
# ``.`` ``..`` ``""`` セグメントは通常 path 構成要素なので skip、resolve +
|
|
120
|
+
# containment check で扱う。
|
|
121
|
+
for seg in decoded.split("/"):
|
|
122
|
+
if seg in ("", ".", ".."):
|
|
123
|
+
continue
|
|
124
|
+
if seg.endswith((" ", ".")):
|
|
125
|
+
raise PathTraversalError(f"Path segment with trailing space or dot: {seg!r}")
|
|
126
|
+
# 拡張子前の base 部分のみで予約名判定 (= ``CON.flw.json`` も拒否)。
|
|
127
|
+
base = seg.split(".", 1)[0].lower()
|
|
128
|
+
if base in _WIN_RESERVED_NAMES:
|
|
129
|
+
raise PathTraversalError(f"Windows reserved name: {seg!r}")
|
|
130
|
+
|
|
131
|
+
# Step 6: resolve under workspace_root
|
|
132
|
+
# ``strict=False`` で存在しない path も resolve、symlink は辿る。
|
|
133
|
+
root_resolved = workspace_root.resolve(strict=False)
|
|
134
|
+
candidate = (root_resolved / decoded).resolve(strict=False)
|
|
135
|
+
|
|
136
|
+
# Step 7: containment check
|
|
137
|
+
# symlink 経由での root 外脱出はここで弾かれる (resolve が symlink を辿るため)。
|
|
138
|
+
try:
|
|
139
|
+
candidate.relative_to(root_resolved)
|
|
140
|
+
except ValueError as e:
|
|
141
|
+
raise PathTraversalError(f"Path escapes workspace root: {decoded!r}") from e
|
|
142
|
+
|
|
143
|
+
return candidate
|
flode/server/settings.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""サーバ設定 (ADR-0011 §(6) / ADR-0041 §3、v0.21.0 で legacy ``model_dir`` を削除)。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Settings:
|
|
11
|
+
"""``create_app`` に渡す設定。
|
|
12
|
+
|
|
13
|
+
Attributes:
|
|
14
|
+
workspace_root: File API ``/api/v1/files/*`` のルートディレクトリ
|
|
15
|
+
(ADR-0041 §3、v0.15.0 新設、v0.21.0 で必須化)。``--workspace=PATH``
|
|
16
|
+
CLI 引数または ``Settings(workspace_root=...)`` 直接指定で設定。
|
|
17
|
+
scope_batch_size: WebSocket での Scope データ送信のバッチサイズ
|
|
18
|
+
(default 100、ADR-0011 §(2))。
|
|
19
|
+
max_concurrent: 同時実行可能なシミュレーション数 (default 4)。
|
|
20
|
+
allow_origins: CORS 許可オリジン (default 空 = CORS 無効)。
|
|
21
|
+
Frontend 別ホスト開発時に opt-in。
|
|
22
|
+
library_paths: ``.flwlib.json`` のロード対象 path リスト (ADR-0029、v0.11.1)。
|
|
23
|
+
ファイル / ディレクトリのいずれも可 (ディレクトリの場合は ``*.flwlib.json``
|
|
24
|
+
を再帰的に検索する) 。空リスト + ``bundle_builtin_libraries=True`` だけでも
|
|
25
|
+
組み込み ``std`` が利用可能になる。
|
|
26
|
+
bundle_builtin_libraries: 組み込み ``flode/libraries/std.flwlib.json`` を
|
|
27
|
+
自動的に library registry に追加するか (default ``True``、ADR-0029 §LOC-A)。
|
|
28
|
+
python_blocks_allowed: ``PythonFunction`` の実行可否 (SPEC-0023 / ADR-0073 hard gate)。
|
|
29
|
+
``create_app`` がプロセス policy に適用する。CLI は bind host とフラグから
|
|
30
|
+
決めた値を入れる (loopback → True、非 loopback はフラグ必須)。``create_app`` を
|
|
31
|
+
直接使う埋め込み利用者は **公開 bind なら必ず False にする** こと。
|
|
32
|
+
python_blocks_reason: 拒否理由 (エラーメッセージに埋め込む、``allowed=False`` 時のみ)。
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
workspace_root: Path
|
|
36
|
+
scope_batch_size: int = 100
|
|
37
|
+
max_concurrent: int = 4
|
|
38
|
+
allow_origins: list[str] = field(default_factory=list)
|
|
39
|
+
library_paths: list[Path] = field(default_factory=list)
|
|
40
|
+
bundle_builtin_libraries: bool = True
|
|
41
|
+
python_blocks_allowed: bool = True
|
|
42
|
+
python_blocks_reason: str = ""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.60.3
|