sqlseed-web 0.2.4__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.
- sqlseed_web/AGENTS.md +106 -0
- sqlseed_web/__init__.py +24 -0
- sqlseed_web/__main__.py +8 -0
- sqlseed_web/_application.py +205 -0
- sqlseed_web/ai_settings.py +290 -0
- sqlseed_web/api.py +1102 -0
- sqlseed_web/app.py +26 -0
- sqlseed_web/managed_worker.py +184 -0
- sqlseed_web/operation_errors.py +42 -0
- sqlseed_web/plugin_environment.py +231 -0
- sqlseed_web/plugin_management.py +322 -0
- sqlseed_web/plugin_process.py +131 -0
- sqlseed_web/runtime_lifecycle.py +130 -0
- sqlseed_web/runtime_session.py +97 -0
- sqlseed_web/settings_environment.py +368 -0
- sqlseed_web/sqlite_target.py +86 -0
- sqlseed_web/state.py +348 -0
- sqlseed_web/static/AGENTS.md +180 -0
- sqlseed_web/static/ai.css +57 -0
- sqlseed_web/static/configs.css +50 -0
- sqlseed_web/static/date-picker.css +230 -0
- sqlseed_web/static/disclosure.css +149 -0
- sqlseed_web/static/graph-clarity.css +103 -0
- sqlseed_web/static/index.html +29 -0
- sqlseed_web/static/js/api.js +228 -0
- sqlseed_web/static/js/app.js +97 -0
- sqlseed_web/static/js/dropdown.js +432 -0
- sqlseed_web/static/js/filepicker.js +203 -0
- sqlseed_web/static/js/genform.js +1066 -0
- sqlseed_web/static/js/labels.js +195 -0
- sqlseed_web/static/js/pages/browse.js +209 -0
- sqlseed_web/static/js/pages/configs.js +424 -0
- sqlseed_web/static/js/pages/connect.js +332 -0
- sqlseed_web/static/js/pages/heal.js +395 -0
- sqlseed_web/static/js/pages/meta.js +110 -0
- sqlseed_web/static/js/pages/runs.js +293 -0
- sqlseed_web/static/js/pages/settings.js +942 -0
- sqlseed_web/static/js/pages/wizard.js +751 -0
- sqlseed_web/static/js/pages/workbench.js +3123 -0
- sqlseed_web/static/js/tree.js +126 -0
- sqlseed_web/static/js/workbench/ai-eligibility.js +33 -0
- sqlseed_web/static/js/workbench/ai-handoff.js +31 -0
- sqlseed_web/static/js/workbench/ai-stream.js +116 -0
- sqlseed_web/static/js/workbench/ai.js +888 -0
- sqlseed_web/static/js/workbench/connection.js +508 -0
- sqlseed_web/static/js/workbench/date-picker.js +445 -0
- sqlseed_web/static/js/workbench/dependency-view.js +119 -0
- sqlseed_web/static/js/workbench/editor.js +1236 -0
- sqlseed_web/static/js/workbench/focus.js +11 -0
- sqlseed_web/static/js/workbench/graph-layout.js +332 -0
- sqlseed_web/static/js/workbench/graph.js +970 -0
- sqlseed_web/static/js/workbench/guidance.js +29 -0
- sqlseed_web/static/js/workbench/model.js +124 -0
- sqlseed_web/static/js/workbench/plugin-management.js +512 -0
- sqlseed_web/static/js/workbench/preview-scroll-layout.js +94 -0
- sqlseed_web/static/js/workbench/preview.js +572 -0
- sqlseed_web/static/js/workbench/provider-guide.js +33 -0
- sqlseed_web/static/js/workbench/recovery.js +28 -0
- sqlseed_web/static/js/workbench/scroll-lock.js +26 -0
- sqlseed_web/static/js/workbench/session.js +174 -0
- sqlseed_web/static/js/workbench/table-data.js +186 -0
- sqlseed_web/static/js/workbench/ui.js +262 -0
- sqlseed_web/static/navigation.css +92 -0
- sqlseed_web/static/preview.css +29 -0
- sqlseed_web/static/runs.css +53 -0
- sqlseed_web/static/scrollbars.css +42 -0
- sqlseed_web/static/settings.css +108 -0
- sqlseed_web/static/style.css +3382 -0
- sqlseed_web/static/table-data.css +27 -0
- sqlseed_web/static/workbench.css +509 -0
- sqlseed_web/supervised_plugins.py +173 -0
- sqlseed_web/supervisor.py +238 -0
- sqlseed_web/workbench.py +381 -0
- sqlseed_web/workbench_ai.py +887 -0
- sqlseed_web/workbench_ai_relations.py +285 -0
- sqlseed_web/workbench_ai_stream.py +172 -0
- sqlseed_web/workbench_data.py +163 -0
- sqlseed_web/workbench_execution.py +199 -0
- sqlseed_web/workbench_runtime.py +1218 -0
- sqlseed_web/workbench_schema.py +277 -0
- sqlseed_web/workbench_store.py +458 -0
- sqlseed_web/worker_control.py +192 -0
- sqlseed_web-0.2.4.dist-info/METADATA +105 -0
- sqlseed_web-0.2.4.dist-info/RECORD +87 -0
- sqlseed_web-0.2.4.dist-info/WHEEL +4 -0
- sqlseed_web-0.2.4.dist-info/entry_points.txt +2 -0
- sqlseed_web-0.2.4.dist-info/licenses/LICENSE +679 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Opt-in maintenance API for a fixed set of optional components."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hmac
|
|
6
|
+
import ipaddress
|
|
7
|
+
import secrets
|
|
8
|
+
import shlex
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Literal, Protocol
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
19
|
+
from pydantic import BaseModel, ConfigDict
|
|
20
|
+
|
|
21
|
+
from sqlseed_web import plugin_environment
|
|
22
|
+
from sqlseed_web.plugin_environment import COMPONENT_DISTRIBUTIONS, EnvironmentLock, InstalledPackage
|
|
23
|
+
from sqlseed_web.plugin_process import run_installer
|
|
24
|
+
|
|
25
|
+
router = APIRouter(prefix="/api/settings/plugins", tags=["plugin-maintenance"])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PlanRequest(BaseModel):
|
|
29
|
+
model_config = ConfigDict(extra="forbid")
|
|
30
|
+
component_id: Literal["ai", "cli", "mcp", "mimesis"]
|
|
31
|
+
action: Literal["install", "uninstall"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ExecuteRequest(BaseModel):
|
|
35
|
+
model_config = ConfigDict(extra="forbid")
|
|
36
|
+
plan_id: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ManagementService(Protocol):
|
|
40
|
+
enabled: bool
|
|
41
|
+
token: str
|
|
42
|
+
|
|
43
|
+
def status(self) -> dict[str, Any]: ...
|
|
44
|
+
def plan(self, body: PlanRequest) -> dict[str, Any]: ...
|
|
45
|
+
def execute(self, body: ExecuteRequest) -> dict[str, Any]: ...
|
|
46
|
+
def task_snapshot(self, task_id: str | None = None) -> dict[str, Any]: ...
|
|
47
|
+
def recover(self) -> dict[str, Any]: ...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _reject(message: str, code: str = "plugin_management_unavailable", status: int = 409) -> HTTPException:
|
|
51
|
+
return HTTPException(status_code=status, detail={"code": code, "message": message})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _loopback(host: str) -> bool:
|
|
55
|
+
if host == "localhost":
|
|
56
|
+
return True
|
|
57
|
+
try:
|
|
58
|
+
return ipaddress.ip_address(host).is_loopback
|
|
59
|
+
except ValueError:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def guard_request(request: Request, manager: ManagementService) -> None:
|
|
64
|
+
"""Check transport-derived client, literal Host, same Origin, and a per-process nonce."""
|
|
65
|
+
hosts = request.headers.getlist("host")
|
|
66
|
+
try:
|
|
67
|
+
parsed = urlsplit(f"//{hosts[0]}") if len(hosts) == 1 else None
|
|
68
|
+
valid_host = parsed is not None and parsed.hostname is not None and _loopback(parsed.hostname)
|
|
69
|
+
valid_host = valid_host and parsed is not None and not parsed.username and not parsed.password
|
|
70
|
+
valid_host = valid_host and parsed is not None and not parsed.path and not parsed.query and not parsed.fragment
|
|
71
|
+
if parsed is not None:
|
|
72
|
+
_ = parsed.port
|
|
73
|
+
except ValueError:
|
|
74
|
+
valid_host = False
|
|
75
|
+
if not valid_host or request.client is None or not _loopback(request.client.host):
|
|
76
|
+
raise _reject("插件管理仅允许本机访问。", "plugin_management_forbidden", 403)
|
|
77
|
+
origin = request.headers.get("origin")
|
|
78
|
+
expected = f"{request.url.scheme}://{hosts[0]}"
|
|
79
|
+
if origin is not None and origin != expected:
|
|
80
|
+
raise _reject("插件管理请求必须来自当前页面。", "plugin_management_forbidden", 403)
|
|
81
|
+
if request.headers.get("sec-fetch-site") == "cross-site":
|
|
82
|
+
raise _reject("插件管理请求必须来自当前页面。", "plugin_management_forbidden", 403)
|
|
83
|
+
if request.method not in {"GET", "HEAD"}:
|
|
84
|
+
token = request.headers.get("x-sqlseed-management-token", "")
|
|
85
|
+
if origin != expected or not hmac.compare_digest(token, manager.token):
|
|
86
|
+
raise _reject("插件管理请求缺少有效的页面凭据。", "plugin_management_forbidden", 403)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class PluginManager:
|
|
90
|
+
"""One reviewable plan and one task, protected for the server lifetime."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, *, enabled: bool) -> None:
|
|
93
|
+
self.enabled = enabled
|
|
94
|
+
self.token = secrets.token_urlsafe(32)
|
|
95
|
+
self.environment = plugin_environment._environment()
|
|
96
|
+
self.restart_required = False
|
|
97
|
+
self._lock = threading.RLock()
|
|
98
|
+
self._environment_lock: EnvironmentLock | None = None
|
|
99
|
+
self._startup_reason: str | None = None
|
|
100
|
+
self._plan: dict[str, Any] | None = None
|
|
101
|
+
self._snapshot: dict[str, InstalledPackage] = {}
|
|
102
|
+
self._task: dict[str, Any] | None = None
|
|
103
|
+
self._worker: threading.Thread | None = None
|
|
104
|
+
|
|
105
|
+
def start(self) -> None:
|
|
106
|
+
prefix = self.environment.prefix
|
|
107
|
+
if not (prefix / "pyvenv.cfg").is_file() or (not self.enabled and self.environment.reason):
|
|
108
|
+
return
|
|
109
|
+
environment_lock = EnvironmentLock(prefix, exclusive=self.enabled)
|
|
110
|
+
try:
|
|
111
|
+
environment_lock.acquire()
|
|
112
|
+
except (OSError, RuntimeError) as exc:
|
|
113
|
+
if not self.enabled:
|
|
114
|
+
raise RuntimeError("无法领取 Web 环境使用锁;请检查权限或停止正在运行的维护服务。") from exc
|
|
115
|
+
self._startup_reason = "无法领取维护锁;请检查权限并停止使用此环境的其他 Web 进程。"
|
|
116
|
+
return
|
|
117
|
+
self._environment_lock = environment_lock
|
|
118
|
+
|
|
119
|
+
def stop(self) -> None:
|
|
120
|
+
if self._worker is not None:
|
|
121
|
+
self._worker.join()
|
|
122
|
+
if self._environment_lock is not None:
|
|
123
|
+
self._environment_lock.release()
|
|
124
|
+
self._environment_lock = None
|
|
125
|
+
|
|
126
|
+
def _reason(self) -> str | None:
|
|
127
|
+
if not self.enabled:
|
|
128
|
+
return "此部署由外部服务托管,暂不支持网页安装或卸载;请联系部署管理员。"
|
|
129
|
+
if self.restart_required:
|
|
130
|
+
return "环境已执行变更,请先停止维护服务并正常重启 Web 验证。"
|
|
131
|
+
if self._task and self._task["status"] == "running":
|
|
132
|
+
return "已有组件操作正在执行。"
|
|
133
|
+
if self.environment.reason:
|
|
134
|
+
return self.environment.reason
|
|
135
|
+
if self._startup_reason:
|
|
136
|
+
return self._startup_reason
|
|
137
|
+
if self._environment_lock is None:
|
|
138
|
+
return "维护服务尚未领取环境锁。"
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
def status(self) -> dict[str, Any]:
|
|
142
|
+
with self._lock:
|
|
143
|
+
reason = self._reason()
|
|
144
|
+
try:
|
|
145
|
+
components = plugin_environment.package_status(
|
|
146
|
+
plugin_environment.installed_packages(self.environment.prefix), reason
|
|
147
|
+
)
|
|
148
|
+
except RuntimeError:
|
|
149
|
+
reason = "已安装组件的元数据无法安全解析;请先修复当前 Python 环境。"
|
|
150
|
+
components = []
|
|
151
|
+
args = [self.environment.executable, "-m", "sqlseed_web", "--manage-plugins"]
|
|
152
|
+
command = shlex.join(args)
|
|
153
|
+
if sys.platform == "win32":
|
|
154
|
+
command = "& " + " ".join("'" + arg.replace("'", "''") + "'" for arg in args)
|
|
155
|
+
return {
|
|
156
|
+
"enabled": self.enabled,
|
|
157
|
+
"automatic_lifecycle": False,
|
|
158
|
+
"available": reason is None,
|
|
159
|
+
"reason": reason,
|
|
160
|
+
"maintenance_command": command,
|
|
161
|
+
"python_executable": self.environment.executable,
|
|
162
|
+
"token": self.token if self.enabled else None,
|
|
163
|
+
"restart_required": self.restart_required,
|
|
164
|
+
"active_task": self.task_snapshot() if self._task else None,
|
|
165
|
+
"components": components,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
def plan(self, body: PlanRequest) -> dict[str, Any]:
|
|
169
|
+
with self._lock:
|
|
170
|
+
if reason := self._reason():
|
|
171
|
+
raise _reject(reason)
|
|
172
|
+
try:
|
|
173
|
+
packages = plugin_environment.installed_packages(self.environment.prefix)
|
|
174
|
+
except RuntimeError as exc:
|
|
175
|
+
raise _reject(str(exc)) from exc
|
|
176
|
+
distribution = COMPONENT_DISTRIBUTIONS[body.component_id]
|
|
177
|
+
package = packages.get(distribution)
|
|
178
|
+
if body.action == "install" and package is not None:
|
|
179
|
+
raise _reject("此组件已经安装;加载异常请使用修复指引。")
|
|
180
|
+
if body.action == "uninstall":
|
|
181
|
+
if package is None:
|
|
182
|
+
raise _reject("此组件尚未安装。")
|
|
183
|
+
if users := plugin_environment.required_by(distribution, packages):
|
|
184
|
+
raise _reject(f"由 {', '.join(users)} 使用,请先卸载这些可选组件。")
|
|
185
|
+
self._snapshot = packages
|
|
186
|
+
self._plan = {
|
|
187
|
+
"plan_id": secrets.token_urlsafe(24),
|
|
188
|
+
"component_id": body.component_id,
|
|
189
|
+
"action": body.action,
|
|
190
|
+
"distribution": distribution,
|
|
191
|
+
"version": package.version if package else None,
|
|
192
|
+
"summary": f"{'安装' if body.action == 'install' else '卸载'} {distribution},目标是当前 Web 的 Python 环境。",
|
|
193
|
+
"warnings": [
|
|
194
|
+
"安装会访问软件包源,并冻结所有已安装组件的版本;不兼容时失败,不自动升级。"
|
|
195
|
+
if body.action == "install"
|
|
196
|
+
else "仅卸载选中的组件,不自动卸载其依赖。重新安装需要软件源提供兼容版本;开发版或本地安装的组件可能无法恢复。",
|
|
197
|
+
"完成或失败后均需停止维护服务,正常重启 Web 并检查组件状态。",
|
|
198
|
+
],
|
|
199
|
+
"expires_in": 300,
|
|
200
|
+
"expires_at": time.monotonic() + 300,
|
|
201
|
+
}
|
|
202
|
+
return {key: value for key, value in self._plan.items() if key != "expires_at"}
|
|
203
|
+
|
|
204
|
+
def execute(self, body: ExecuteRequest) -> dict[str, Any]:
|
|
205
|
+
with self._lock:
|
|
206
|
+
if reason := self._reason():
|
|
207
|
+
raise _reject(reason)
|
|
208
|
+
operation_plan = self._plan
|
|
209
|
+
if (
|
|
210
|
+
operation_plan is None
|
|
211
|
+
or not hmac.compare_digest(operation_plan["plan_id"], body.plan_id)
|
|
212
|
+
or time.monotonic() > operation_plan["expires_at"]
|
|
213
|
+
):
|
|
214
|
+
raise _reject("操作计划已失效,请重新查看并确认。")
|
|
215
|
+
try:
|
|
216
|
+
current = plugin_environment.installed_packages(self.environment.prefix)
|
|
217
|
+
except RuntimeError as exc:
|
|
218
|
+
raise _reject(str(exc)) from exc
|
|
219
|
+
if current != self._snapshot or plugin_environment._environment() != self.environment:
|
|
220
|
+
self._plan = None
|
|
221
|
+
raise _reject("Python 环境已发生变化,请重新查看并确认操作计划。")
|
|
222
|
+
self._task = {
|
|
223
|
+
"task_id": secrets.token_urlsafe(24),
|
|
224
|
+
"component_id": operation_plan["component_id"],
|
|
225
|
+
"action": operation_plan["action"],
|
|
226
|
+
"status": "running",
|
|
227
|
+
"output": [],
|
|
228
|
+
"message": "正在准备环境操作。",
|
|
229
|
+
"restart_required": False,
|
|
230
|
+
"returncode": None,
|
|
231
|
+
}
|
|
232
|
+
self._plan = None
|
|
233
|
+
self._worker = threading.Thread(
|
|
234
|
+
target=self._run, args=(operation_plan, current), daemon=False, name="sqlseed-plugin-install"
|
|
235
|
+
)
|
|
236
|
+
try:
|
|
237
|
+
self._worker.start()
|
|
238
|
+
except RuntimeError:
|
|
239
|
+
self._worker = None
|
|
240
|
+
self._task.update(status="failed", message="无法启动组件操作。")
|
|
241
|
+
return self.task_snapshot()
|
|
242
|
+
|
|
243
|
+
def task_snapshot(self, task_id: str | None = None) -> dict[str, Any]:
|
|
244
|
+
with self._lock:
|
|
245
|
+
if self._task is None or (task_id is not None and self._task["task_id"] != task_id):
|
|
246
|
+
raise _reject("找不到此组件操作;服务重启后任务记录不再保留。", "plugin_task_not_found", 404)
|
|
247
|
+
return {**self._task, "output": list(self._task["output"])}
|
|
248
|
+
|
|
249
|
+
def _output(self, text: str) -> None:
|
|
250
|
+
with self._lock:
|
|
251
|
+
if self._task is not None and len(self._task["output"]) < 200:
|
|
252
|
+
self._task["output"].append(text[:2000])
|
|
253
|
+
|
|
254
|
+
def _run(self, operation_plan: dict[str, Any], before: dict[str, InstalledPackage]) -> None:
|
|
255
|
+
result = None
|
|
256
|
+
succeeded = False
|
|
257
|
+
message = "组件操作失败;请检查输出并使用原环境管理工具修复。"
|
|
258
|
+
try:
|
|
259
|
+
with tempfile.TemporaryDirectory(prefix="sqlseed-plugin-plan-") as directory:
|
|
260
|
+
constraints = Path(directory) / "constraints.txt"
|
|
261
|
+
constraints.write_text(
|
|
262
|
+
"".join(f"{name}=={package.version}\n" for name, package in sorted(before.items())),
|
|
263
|
+
encoding="utf-8",
|
|
264
|
+
)
|
|
265
|
+
arguments = plugin_environment.installer_arguments(
|
|
266
|
+
self.environment, operation_plan["action"], operation_plan["distribution"], constraints
|
|
267
|
+
)
|
|
268
|
+
with self._lock:
|
|
269
|
+
self.restart_required = True
|
|
270
|
+
if self._task is not None:
|
|
271
|
+
self._task.update(restart_required=True, message="安装工具正在运行,请等待完成。")
|
|
272
|
+
if self._environment_lock is None:
|
|
273
|
+
raise RuntimeError("环境锁已失效,未执行安装工具。")
|
|
274
|
+
result = run_installer(arguments, self._output, lock_descriptor=self._environment_lock.fileno())
|
|
275
|
+
after = plugin_environment.installed_packages(self.environment.prefix)
|
|
276
|
+
target = operation_plan["distribution"]
|
|
277
|
+
expected_target = target in after if operation_plan["action"] == "install" else target not in after
|
|
278
|
+
preserved = all(after.get(name) == package for name, package in before.items() if name != target)
|
|
279
|
+
if succeeded := result == 0 and expected_target and preserved:
|
|
280
|
+
message = "组件操作完成;请停止维护服务并正常重启 Web 验证。"
|
|
281
|
+
elif result == 0:
|
|
282
|
+
message = "安装工具已退出,但组件元数据核验未通过;请检查环境并重启 Web。"
|
|
283
|
+
except (OSError, RuntimeError, ValueError, subprocess.SubprocessError):
|
|
284
|
+
self._output("无法完成环境操作;请使用原环境管理工具检查。")
|
|
285
|
+
finally:
|
|
286
|
+
with self._lock:
|
|
287
|
+
if self._task is not None:
|
|
288
|
+
self._task.update(status="succeeded" if succeeded else "failed", message=message, returncode=result)
|
|
289
|
+
|
|
290
|
+
def recover(self) -> dict[str, Any]:
|
|
291
|
+
raise _reject("此部署不支持自动恢复服务。")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _manager(request: Request) -> ManagementService:
|
|
295
|
+
manager: ManagementService = request.app.state.plugin_manager
|
|
296
|
+
guard_request(request, manager)
|
|
297
|
+
return manager
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@router.get("/management")
|
|
301
|
+
def management(request: Request) -> dict[str, Any]:
|
|
302
|
+
return _manager(request).status()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@router.post("/plan")
|
|
306
|
+
def plan(body: PlanRequest, request: Request) -> dict[str, Any]:
|
|
307
|
+
return _manager(request).plan(body)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@router.post("/execute", status_code=202)
|
|
311
|
+
def execute(body: ExecuteRequest, request: Request) -> dict[str, Any]:
|
|
312
|
+
return _manager(request).execute(body)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@router.get("/tasks/{task_id}")
|
|
316
|
+
def task(task_id: str, request: Request) -> dict[str, Any]:
|
|
317
|
+
return _manager(request).task_snapshot(task_id)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@router.post("/recover", status_code=202)
|
|
321
|
+
def recover(request: Request) -> dict[str, Any]:
|
|
322
|
+
return _manager(request).recover()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Bounded installer subprocess execution; never expose raw output or secrets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable, Iterator
|
|
12
|
+
from typing import IO
|
|
13
|
+
|
|
14
|
+
from sqlseed._utils.daemon_task import DaemonTask
|
|
15
|
+
|
|
16
|
+
OUTPUT_LIMIT = 24_000
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _sanitized(text: str) -> str:
|
|
20
|
+
text = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", text)
|
|
21
|
+
text = re.sub(r"(?i)\b(?:https?|ftp|file)://\S+", "[地址已隐藏]", text)
|
|
22
|
+
text = re.sub(
|
|
23
|
+
r"(?i)\b(?:authorization|password|passwd|token|api[_-]?key|secret)\b\s*[:=]\s*\S+", "[敏感信息已隐藏]", text
|
|
24
|
+
)
|
|
25
|
+
for name, value in os.environ.items():
|
|
26
|
+
if len(value) >= 6 and re.search(r"(?i)(key|token|password|secret|credential)", name):
|
|
27
|
+
text = text.replace(value, "[敏感信息已隐藏]")
|
|
28
|
+
return "".join(character for character in text if character in "\n\t" or ord(character) >= 32)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _installer_lines(stream: IO[bytes]) -> Iterator[tuple[bytes, int]]:
|
|
32
|
+
"""Drain complete lines, dropping an oversized line through its newline."""
|
|
33
|
+
pending = b""
|
|
34
|
+
dropping_line = False
|
|
35
|
+
while chunk := os.read(stream.fileno(), 1024):
|
|
36
|
+
if dropping_line:
|
|
37
|
+
if b"\n" not in chunk:
|
|
38
|
+
continue
|
|
39
|
+
_, chunk = chunk.split(b"\n", 1)
|
|
40
|
+
dropping_line = False
|
|
41
|
+
pending += chunk
|
|
42
|
+
while b"\n" in pending or len(pending) > 4096:
|
|
43
|
+
if b"\n" in pending:
|
|
44
|
+
line, pending = pending.split(b"\n", 1)
|
|
45
|
+
else:
|
|
46
|
+
# Discard overlong lines as a whole: do not reveal split credentials/URLs.
|
|
47
|
+
pending = b""
|
|
48
|
+
dropping_line = True
|
|
49
|
+
line = b"[overlong installer output omitted]"
|
|
50
|
+
yield line, 2000
|
|
51
|
+
if pending:
|
|
52
|
+
# A final partial line retains the existing overall-budget truncation.
|
|
53
|
+
yield pending, OUTPUT_LIMIT
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _read_installer_output(stream: IO[bytes], output: Callable[[str], None]) -> None:
|
|
57
|
+
remaining = OUTPUT_LIMIT
|
|
58
|
+
for line, line_limit in _installer_lines(stream):
|
|
59
|
+
if remaining <= 0:
|
|
60
|
+
continue
|
|
61
|
+
if clean := _sanitized(line.decode("utf-8", errors="replace"))[: min(remaining, line_limit)]:
|
|
62
|
+
output(clean)
|
|
63
|
+
remaining -= len(clean)
|
|
64
|
+
if remaining <= 0:
|
|
65
|
+
output("输出已达到长度上限,后续输出已省略。")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def run_installer(
|
|
69
|
+
arguments: list[str], output: Callable[[str], None], *, timeout: float = 300, lock_descriptor: int | None = None
|
|
70
|
+
) -> int:
|
|
71
|
+
"""Stream bounded text, kill a timed-out process group, then reap the child."""
|
|
72
|
+
environment = {
|
|
73
|
+
name: value
|
|
74
|
+
for name, value in os.environ.items()
|
|
75
|
+
if not name.startswith(("PIP_", "UV_", "PYTHON")) and name != "VIRTUAL_ENV"
|
|
76
|
+
}
|
|
77
|
+
environment.update({"PIP_CONFIG_FILE": os.devnull, "PYTHONNOUSERSITE": "1", "NO_COLOR": "1"})
|
|
78
|
+
with (
|
|
79
|
+
tempfile.TemporaryDirectory(prefix="sqlseed-plugin-process-") as directory,
|
|
80
|
+
subprocess.Popen(
|
|
81
|
+
arguments,
|
|
82
|
+
stdin=subprocess.DEVNULL,
|
|
83
|
+
stdout=subprocess.PIPE,
|
|
84
|
+
stderr=subprocess.STDOUT,
|
|
85
|
+
cwd=directory,
|
|
86
|
+
env=environment,
|
|
87
|
+
start_new_session=os.name != "nt",
|
|
88
|
+
# Children retain the environment lock until they have exited.
|
|
89
|
+
pass_fds=(lock_descriptor,) if os.name != "nt" and lock_descriptor is not None else (),
|
|
90
|
+
) as process,
|
|
91
|
+
):
|
|
92
|
+
reader: DaemonTask[None] | None = None
|
|
93
|
+
stopped = False
|
|
94
|
+
try:
|
|
95
|
+
if (stream := process.stdout) is None:
|
|
96
|
+
raise RuntimeError("无法读取安装工具输出。")
|
|
97
|
+
reader = DaemonTask(lambda: _read_installer_output(stream, output), name="sqlseed-plugin-output")
|
|
98
|
+
deadline = time.monotonic() + timeout
|
|
99
|
+
try:
|
|
100
|
+
result = process.wait(timeout=timeout)
|
|
101
|
+
if not reader.wait(max(0, deadline - time.monotonic())):
|
|
102
|
+
raise subprocess.TimeoutExpired(arguments, timeout)
|
|
103
|
+
except subprocess.TimeoutExpired:
|
|
104
|
+
_stop_installer(process)
|
|
105
|
+
stopped = True
|
|
106
|
+
output("安装工具运行超时,子进程已停止;请检查环境并重启 Web。")
|
|
107
|
+
return -1
|
|
108
|
+
if (error := reader.exception()) is not None:
|
|
109
|
+
raise RuntimeError("无法读取安装工具输出。") from error
|
|
110
|
+
return result
|
|
111
|
+
finally:
|
|
112
|
+
# Startup and output failures own the same cleanup as timeouts.
|
|
113
|
+
# Terminate before Popen.__exit__ waits, including inherited pipes.
|
|
114
|
+
# The process group can outlive its leader while holding pipes
|
|
115
|
+
# and the environment lock, including after a successful exit.
|
|
116
|
+
if not stopped:
|
|
117
|
+
_stop_installer(process)
|
|
118
|
+
if reader is not None:
|
|
119
|
+
reader.wait(2)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _stop_installer(process: subprocess.Popen[bytes]) -> None:
|
|
123
|
+
if os.name == "nt":
|
|
124
|
+
if process.poll() is None:
|
|
125
|
+
process.kill()
|
|
126
|
+
else:
|
|
127
|
+
try:
|
|
128
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
129
|
+
except ProcessLookupError:
|
|
130
|
+
pass
|
|
131
|
+
process.wait()
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Atomic runtime admission and leases held until real request/worker exit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from collections.abc import Callable, Iterator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
from fastapi import HTTPException
|
|
11
|
+
from starlette.responses import JSONResponse
|
|
12
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RuntimeGate:
|
|
16
|
+
"""Reject a runtime pause while any admitted work still owns a lease."""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._lock = threading.Lock()
|
|
20
|
+
self._paused = False
|
|
21
|
+
self._counts = {"requests": 0, "background_jobs": 0, "ai_analyses": 0}
|
|
22
|
+
|
|
23
|
+
def activity(self) -> dict[str, int]:
|
|
24
|
+
with self._lock:
|
|
25
|
+
return dict(self._counts)
|
|
26
|
+
|
|
27
|
+
def close_admission(self) -> dict[str, int]:
|
|
28
|
+
"""Reject new work immediately while existing leases drain normally."""
|
|
29
|
+
with self._lock:
|
|
30
|
+
self._paused = True
|
|
31
|
+
return dict(self._counts)
|
|
32
|
+
|
|
33
|
+
def pause_if_idle(self) -> dict[str, int]:
|
|
34
|
+
with self._lock:
|
|
35
|
+
activity = dict(self._counts)
|
|
36
|
+
if any(activity.values()):
|
|
37
|
+
raise HTTPException(
|
|
38
|
+
409,
|
|
39
|
+
detail={
|
|
40
|
+
"code": "plugin_management_busy",
|
|
41
|
+
"message": "当前仍有请求或后台任务运行,请等待完成后再管理插件。",
|
|
42
|
+
"activity": activity,
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
self._paused = True
|
|
46
|
+
return activity
|
|
47
|
+
|
|
48
|
+
def resume(self) -> None:
|
|
49
|
+
with self._lock:
|
|
50
|
+
self._paused = False
|
|
51
|
+
|
|
52
|
+
def acquire(self, category: Literal["requests", "background_jobs", "ai_analyses"]) -> None:
|
|
53
|
+
with self._lock:
|
|
54
|
+
if self._paused:
|
|
55
|
+
raise HTTPException(
|
|
56
|
+
503,
|
|
57
|
+
detail={
|
|
58
|
+
"code": "plugin_runtime_paused",
|
|
59
|
+
"message": "正在更新插件,服务恢复后即可继续使用。",
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
self._counts[category] += 1
|
|
63
|
+
|
|
64
|
+
def release(self, category: Literal["requests", "background_jobs", "ai_analyses"]) -> None:
|
|
65
|
+
with self._lock:
|
|
66
|
+
if self._counts[category] <= 0:
|
|
67
|
+
raise RuntimeError("A runtime lease cannot be released twice")
|
|
68
|
+
self._counts[category] -= 1
|
|
69
|
+
|
|
70
|
+
@contextmanager
|
|
71
|
+
def request(self) -> Iterator[None]:
|
|
72
|
+
self.acquire("requests")
|
|
73
|
+
try:
|
|
74
|
+
yield
|
|
75
|
+
finally:
|
|
76
|
+
self.release("requests")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
runtime_gate = RuntimeGate()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class RuntimeAdmissionMiddleware:
|
|
83
|
+
"""Count complete ASGI calls, including streams and response background work."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
86
|
+
self.app = app
|
|
87
|
+
|
|
88
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
89
|
+
if scope["type"] != "http" or scope.get("path", "").startswith("/api/settings/plugins/"):
|
|
90
|
+
await self.app(scope, receive, send)
|
|
91
|
+
return
|
|
92
|
+
gate = runtime_gate
|
|
93
|
+
try:
|
|
94
|
+
gate.acquire("requests")
|
|
95
|
+
except HTTPException as exc:
|
|
96
|
+
await JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})(scope, receive, send)
|
|
97
|
+
return
|
|
98
|
+
try:
|
|
99
|
+
await self.app(scope, receive, send)
|
|
100
|
+
finally:
|
|
101
|
+
gate.release("requests")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def start_background(
|
|
105
|
+
*,
|
|
106
|
+
target: Callable[..., Any],
|
|
107
|
+
args: tuple[Any, ...] = (),
|
|
108
|
+
kwargs: dict[str, Any] | None = None,
|
|
109
|
+
category: Literal["job", "ai"],
|
|
110
|
+
daemon: bool = True,
|
|
111
|
+
name: str | None = None,
|
|
112
|
+
) -> threading.Thread:
|
|
113
|
+
"""Reserve before startup; release only when the target has actually exited."""
|
|
114
|
+
gate = runtime_gate
|
|
115
|
+
lease: Literal["background_jobs", "ai_analyses"] = "ai_analyses" if category == "ai" else "background_jobs"
|
|
116
|
+
gate.acquire(lease)
|
|
117
|
+
|
|
118
|
+
def run() -> None:
|
|
119
|
+
try:
|
|
120
|
+
target(*args, **(kwargs or {}))
|
|
121
|
+
finally:
|
|
122
|
+
gate.release(lease)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
thread = threading.Thread(target=run, daemon=daemon, name=name)
|
|
126
|
+
thread.start()
|
|
127
|
+
except BaseException:
|
|
128
|
+
gate.release(lease)
|
|
129
|
+
raise
|
|
130
|
+
return thread
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Transfer live connection settings across managed workers through memory only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import HTTPException
|
|
9
|
+
from sqlseed._utils.logger import get_logger
|
|
10
|
+
|
|
11
|
+
from sqlseed_web.operation_errors import generation_errors
|
|
12
|
+
from sqlseed_web.sqlite_target import sqlite_target
|
|
13
|
+
from sqlseed_web.state import Connection, state
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def export_session() -> dict[str, Any]:
|
|
19
|
+
"""Snapshot an idle worker; memory databases cannot survive its replacement."""
|
|
20
|
+
connections = []
|
|
21
|
+
for item in state.list_connections():
|
|
22
|
+
target = sqlite_target(item["target"], item["conn_id"])
|
|
23
|
+
if target is not None and target.kind != "sqlite":
|
|
24
|
+
raise HTTPException(
|
|
25
|
+
409,
|
|
26
|
+
detail={
|
|
27
|
+
"code": "plugin_session_not_restorable",
|
|
28
|
+
"message": "当前连接包含 SQLite 内存数据库,更新插件会丢失内存数据;请先保存数据并断开该连接。",
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
connections.append({key: item[key] for key in ("conn_id", "target", "provider", "locale")})
|
|
32
|
+
return {"connections": connections, "ai_override": state.get_ai_override()}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _restore_connection(item: Any) -> None:
|
|
36
|
+
"""Validate and open one saved connection, unregistering unsuccessful opens."""
|
|
37
|
+
conn: Connection | None = None
|
|
38
|
+
opened = False
|
|
39
|
+
try:
|
|
40
|
+
if not isinstance(item, dict) or not all(
|
|
41
|
+
isinstance(item.get(key), str) and item[key] for key in ("target", "conn_id", "provider", "locale")
|
|
42
|
+
):
|
|
43
|
+
raise ValueError("Invalid saved connection settings")
|
|
44
|
+
target = sqlite_target(item["target"], item["conn_id"])
|
|
45
|
+
if target is not None and (target.kind != "sqlite" or not Path(target.value).is_file()):
|
|
46
|
+
raise ValueError("The original file-backed database is unavailable")
|
|
47
|
+
conn = state.add_connection(
|
|
48
|
+
item["target"], provider=item["provider"], locale=item["locale"], connection_id=item["conn_id"]
|
|
49
|
+
)
|
|
50
|
+
# Registry construction is lazy: exercise the actual opening path.
|
|
51
|
+
conn.orchestrator.get_table_names()
|
|
52
|
+
opened = True
|
|
53
|
+
except generation_errors(conn.orchestrator if conn is not None else None, additional=(KeyError,)) as exc:
|
|
54
|
+
raise ValueError("Saved connection could not be restored") from exc
|
|
55
|
+
finally:
|
|
56
|
+
if conn is not None and not opened:
|
|
57
|
+
try:
|
|
58
|
+
state.close_connection(conn.conn_id)
|
|
59
|
+
except generation_errors(conn.orchestrator, additional=(KeyError,)):
|
|
60
|
+
# The registry removes ownership before disposing the adapter.
|
|
61
|
+
logger.warning("Restored connection adapter cleanup failed", conn_id=conn.conn_id)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def restore_session(snapshot: dict[str, Any]) -> dict[str, Any]:
|
|
65
|
+
"""Reopen each target with the original ID; report sanitized partial failures."""
|
|
66
|
+
restored = 0
|
|
67
|
+
failures: list[dict[str, str]] = []
|
|
68
|
+
items = snapshot.get("connections", [])
|
|
69
|
+
if not isinstance(items, list):
|
|
70
|
+
raise TypeError("Session connections must be a list")
|
|
71
|
+
for item in items:
|
|
72
|
+
try:
|
|
73
|
+
_restore_connection(item)
|
|
74
|
+
except ValueError:
|
|
75
|
+
failures.append(
|
|
76
|
+
{
|
|
77
|
+
"conn_id": str(item.get("conn_id", "")) if isinstance(item, dict) else "",
|
|
78
|
+
"message": "无法恢复此连接,请检查数据库可访问性并重新连接。",
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
else:
|
|
82
|
+
restored += 1
|
|
83
|
+
override = snapshot.get("ai_override", {})
|
|
84
|
+
valid_override = isinstance(override, dict) and all(
|
|
85
|
+
isinstance(k, str) and isinstance(v, str) for k, v in override.items()
|
|
86
|
+
)
|
|
87
|
+
if valid_override:
|
|
88
|
+
# Includes credential-service binding and explicit environment-key
|
|
89
|
+
# suppression markers. Never write these values to settings files.
|
|
90
|
+
state.set_ai_override(override)
|
|
91
|
+
return {"restored_connections": restored, "failed_connections": failures, "ai_session_restored": valid_override}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def close_session() -> None:
|
|
95
|
+
"""Close registered idle connections after runtime admission has paused."""
|
|
96
|
+
for item in state.list_connections():
|
|
97
|
+
state.close_connection(item["conn_id"])
|