funmill-api 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,107 @@
1
+ import os
2
+ import signal
3
+ import subprocess
4
+ import time
5
+ from pathlib import Path
6
+
7
+
8
+ def _pid_path(name: str, directory: Path) -> Path:
9
+ return directory / f"{name}.pid"
10
+
11
+
12
+ def _read_pid(path: Path) -> int:
13
+ try:
14
+ pid = int(path.read_text(encoding="utf-8").strip())
15
+ if pid <= 1:
16
+ raise ValueError
17
+ return pid
18
+ except (OSError, ValueError) as exc:
19
+ raise RuntimeError(f"invalid service PID file: {path}") from exc
20
+
21
+
22
+ def _is_running(pid: int) -> bool:
23
+ try:
24
+ os.kill(pid, 0)
25
+ except ProcessLookupError:
26
+ return False
27
+ except PermissionError:
28
+ return True
29
+ return True
30
+
31
+
32
+ def start_background(
33
+ name: str, command: list[str], environment: dict[str, str], directory: Path
34
+ ) -> None:
35
+ directory.mkdir(parents=True, exist_ok=True)
36
+ pid_path = _pid_path(name, directory)
37
+ if pid_path.exists():
38
+ pid = _read_pid(pid_path)
39
+ if _is_running(pid):
40
+ raise RuntimeError(f"{name} is already running with pid {pid}")
41
+ pid_path.unlink()
42
+
43
+ log_path = directory / f"{name}.log"
44
+ with log_path.open("ab") as output:
45
+ process = subprocess.Popen(
46
+ command,
47
+ env=environment,
48
+ stdin=subprocess.DEVNULL,
49
+ stdout=output,
50
+ stderr=subprocess.STDOUT,
51
+ start_new_session=True,
52
+ )
53
+ pid_path.write_text(f"{process.pid}\n", encoding="utf-8")
54
+ try:
55
+ return_code = process.wait(timeout=0.2)
56
+ except subprocess.TimeoutExpired:
57
+ print(f"started {name}: pid={process.pid}, log={log_path}")
58
+ return
59
+ pid_path.unlink(missing_ok=True)
60
+ raise RuntimeError(
61
+ f"{name} exited during startup with code {return_code}; see {log_path}"
62
+ )
63
+
64
+
65
+ def status_background(name: str, directory: Path) -> bool:
66
+ pid_path = _pid_path(name, directory)
67
+ if not pid_path.exists():
68
+ print(f"{name} is stopped")
69
+ return False
70
+ pid = _read_pid(pid_path)
71
+ if not _is_running(pid):
72
+ pid_path.unlink()
73
+ print(f"{name} is stopped")
74
+ return False
75
+ print(f"{name} is running: pid={pid}")
76
+ return True
77
+
78
+
79
+ def stop_background(name: str, directory: Path, timeout: float = 10) -> None:
80
+ pid_path = _pid_path(name, directory)
81
+ if not pid_path.exists():
82
+ print(f"{name} is already stopped")
83
+ return
84
+ pid = _read_pid(pid_path)
85
+ if not _is_running(pid):
86
+ pid_path.unlink()
87
+ print(f"{name} is already stopped")
88
+ return
89
+ try:
90
+ group_id = os.getpgid(pid)
91
+ except ProcessLookupError:
92
+ pid_path.unlink()
93
+ print(f"{name} is already stopped")
94
+ return
95
+ if group_id != pid:
96
+ raise RuntimeError(
97
+ f"refusing to stop {name}: pid {pid} is not its process group"
98
+ )
99
+
100
+ os.killpg(group_id, signal.SIGTERM)
101
+ deadline = time.monotonic() + timeout
102
+ while _is_running(pid):
103
+ if time.monotonic() >= deadline:
104
+ raise RuntimeError(f"timed out waiting for {name} pid {pid} to stop")
105
+ time.sleep(0.1)
106
+ pid_path.unlink(missing_ok=True)
107
+ print(f"stopped {name}: pid={pid}")
@@ -0,0 +1,112 @@
1
+ # Windmill 简单部署
2
+
3
+ 以下方式不使用 Docker,适用于 Linux x86_64。需要一套可用的 PostgreSQL,
4
+ 以及 `python3`、`uv` 和 Bash;Windmill 会自动初始化数据库表。
5
+
6
+ ## 1. 安装 Funmill 和 Windmill
7
+
8
+ ```bash
9
+ uv sync
10
+ uv run funmill install windmill
11
+ ```
12
+
13
+ Windmill 的二进制和配置文件都会放在
14
+ `~/.farfarfun/funmill/services/windmill/`。安装器会校验官方发行包的
15
+ SHA-256;设置 `FUNMILL_HOME` 可以修改 Funmill 的数据根目录。其他第三方服务
16
+ 同样使用 `~/.farfarfun/funmill/services/<service>/` 目录。
17
+
18
+ ## 2. 准备数据库
19
+
20
+ 在 PostgreSQL 中创建用户和数据库:
21
+
22
+ ```bash
23
+ sudo -u postgres createuser --pwprompt windmill
24
+ sudo -u postgres createdb --owner=windmill windmill
25
+ ```
26
+
27
+ 编辑 `~/.farfarfun/funmill/services/windmill/.env`,写入连接地址:
28
+
29
+ ```dotenv
30
+ DATABASE_URL=postgresql://windmill:数据库密码@127.0.0.1:5432/windmill
31
+ MODE=standalone
32
+ SERVER_BIND_ADDR=0.0.0.0
33
+ ```
34
+
35
+ 安装器首次创建该文件时会设置 `0600` 权限,且不会覆盖已有配置。
36
+ Funmill 启动 Windmill 时会固定使用 `SERVER_BIND_ADDR=0.0.0.0`。
37
+
38
+ ## 3. 启动 Windmill
39
+
40
+ `standalone` 模式会在一个进程中同时运行 Server 和一个 Worker:
41
+
42
+ ```bash
43
+ uv run funmill start windmill
44
+ ```
45
+
46
+ 该命令会在后台启动 Windmill,并打印 PID 和日志路径。PID 与日志分别保存在
47
+ `~/.farfarfun/funmill/services/windmill/windmill.pid` 和 `windmill.log`。
48
+ Windmill 的 Web 界面和原生 API 固定监听 `0.0.0.0:8813`。本机打开
49
+ <http://127.0.0.1:8813>,首次登录使用:
50
+
51
+ ```text
52
+ admin@windmill.dev / changeme
53
+ ```
54
+
55
+ 立即修改密码,并在 `admins` workspace 中创建 API Token。
56
+
57
+ ## 4. 启动 Funmill API
58
+
59
+ 回到 Funmill 仓库根目录执行:
60
+
61
+ ```bash
62
+ FUNMILL_API_KEY='自行设置的接口密钥' \
63
+ FUNMILL_BACKEND=windmill \
64
+ WINDMILL_URL='http://127.0.0.1:8813' \
65
+ WINDMILL_WORKSPACE=admins \
66
+ WINDMILL_TOKEN='刚创建的Windmill-Token' \
67
+ uv run funmill start
68
+ ```
69
+
70
+ Funmill API 固定监听 `0.0.0.0:8812`,并保持前台运行。
71
+
72
+ 验证:
73
+
74
+ ```bash
75
+ curl http://127.0.0.1:8812/health
76
+ FUNMILL_API_KEY='自行设置的接口密钥' ./scripts/smoke.sh
77
+ ```
78
+
79
+ ## 5. 管理后台服务
80
+
81
+ ```bash
82
+ uv run funmill status windmill
83
+ uv run funmill restart windmill
84
+ uv run funmill stop windmill
85
+ tail -f ~/.farfarfun/funmill/services/windmill/windmill.log
86
+ ```
87
+
88
+ 重复启动会被 PID 文件拦截。`stop` 会向 Windmill 的独立进程组发送 `SIGTERM`,
89
+ 等待正常退出后删除 PID 文件;服务异常退出后,`status` 会清理失效 PID。
90
+
91
+ ## 增加 Worker
92
+
93
+ 需要更高并发时,在同一台或其他机器额外启动 Worker。每个进程使用不同的
94
+ `WORKER_SUFFIX`:
95
+
96
+ ```bash
97
+ MODE=worker WORKER_SUFFIX=worker2 uv run funmill start windmill
98
+ MODE=worker WORKER_SUFFIX=worker3 uv run funmill start windmill
99
+ ```
100
+
101
+ 每个 Worker 使用 `windmill-<WORKER_SUFFIX>.pid` 和同名日志。查询或停止某个
102
+ Worker 时需要传入相同环境变量,例如:
103
+
104
+ ```bash
105
+ MODE=worker WORKER_SUFFIX=worker2 uv run funmill status windmill
106
+ MODE=worker WORKER_SUFFIX=worker2 uv run funmill stop windmill
107
+ ```
108
+
109
+ 需要开机启动、自动重启和日志轮转时,使用现有的 systemd 或进程管理器直接
110
+ 管理已安装的 Windmill 二进制。不要在
111
+ 一个普通 Worker 进程中设置 `NUM_WORKERS>1`;Windmill 会因为隔离安全限制将
112
+ 它回退为 1,多个独立 Worker 进程更明确。
@@ -0,0 +1,414 @@
1
+ import json
2
+ import os
3
+ from typing import Any
4
+ from uuid import UUID
5
+
6
+ import httpx
7
+
8
+ from funmill.api.models import (
9
+ TaskDefinition,
10
+ TaskInfo,
11
+ TaskLanguage,
12
+ TaskLogs,
13
+ TaskProgress,
14
+ TaskResult,
15
+ TaskStatus,
16
+ TaskSubmit,
17
+ WorkflowSubmit,
18
+ )
19
+ from funmill.api.ports import THIRD_PARTY_WEB_PORT
20
+
21
+ from ..base import BackendError, TaskBackend
22
+
23
+ _CALLBACK_SOURCE = """import json
24
+ import os
25
+ from urllib.request import Request, urlopen
26
+
27
+ def main(callback_url: str, status: str, payload):
28
+ body = {
29
+ "task_id": os.environ["WM_ROOT_JOB_ID"],
30
+ "status": status,
31
+ "payload": payload,
32
+ }
33
+ request = Request(
34
+ callback_url,
35
+ data=json.dumps(body, default=str).encode(),
36
+ headers={"Content-Type": "application/json"},
37
+ method="POST",
38
+ )
39
+ with urlopen(request, timeout=10):
40
+ return payload
41
+ """
42
+
43
+ _DEPENDENCY_SOURCE = """from datetime import datetime, timezone
44
+ import os
45
+ from wmill import get_job
46
+
47
+ def main(dependencies: list[str], timeout_seconds: int):
48
+ root = get_job(os.environ["WM_ROOT_JOB_ID"])
49
+ created_at = datetime.fromisoformat(root["created_at"].replace("Z", "+00:00"))
50
+ if (datetime.now(timezone.utc) - created_at).total_seconds() > timeout_seconds:
51
+ raise TimeoutError("dependency wait timed out")
52
+
53
+ for task_id in dependencies:
54
+ job = get_job(task_id)
55
+ if "success" not in job:
56
+ return False
57
+ if job.get("canceled"):
58
+ raise RuntimeError(f"dependency {task_id} was canceled")
59
+ if not job["success"]:
60
+ raise RuntimeError(f"dependency {task_id} failed")
61
+ return True
62
+ """
63
+
64
+ _RESULT_SOURCE = """def main(results):
65
+ return results
66
+ """
67
+
68
+ _LANGUAGES = {
69
+ TaskLanguage.PYTHON: "python3",
70
+ TaskLanguage.BASH: "bash",
71
+ }
72
+
73
+
74
+ class WindmillBackend(TaskBackend):
75
+ name = "windmill"
76
+
77
+ def __init__(
78
+ self,
79
+ base_url: str,
80
+ workspace: str,
81
+ token: str,
82
+ timeout: float = 30,
83
+ client: httpx.Client | None = None,
84
+ ) -> None:
85
+ self.token = token
86
+ self.base_url = base_url.rstrip("/")
87
+ self.client = client or httpx.Client(
88
+ base_url=f"{self.base_url}/api/w/{workspace}/",
89
+ timeout=timeout,
90
+ )
91
+
92
+ @classmethod
93
+ def from_env(cls) -> "WindmillBackend":
94
+ return cls(
95
+ base_url=os.getenv(
96
+ "WINDMILL_URL", f"http://127.0.0.1:{THIRD_PARTY_WEB_PORT}"
97
+ ),
98
+ workspace=os.getenv("WINDMILL_WORKSPACE", "admins"),
99
+ token=os.getenv("WINDMILL_TOKEN", ""),
100
+ timeout=float(os.getenv("WINDMILL_TIMEOUT", "30")),
101
+ )
102
+
103
+ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
104
+ if not self.token:
105
+ raise BackendError("WINDMILL_TOKEN is not configured", 503)
106
+ try:
107
+ response = self.client.request(
108
+ method,
109
+ path,
110
+ headers={"Authorization": f"Bearer {self.token}"},
111
+ **kwargs,
112
+ )
113
+ except httpx.TimeoutException as exc:
114
+ raise BackendError("Windmill request timed out", 504) from exc
115
+ except httpx.HTTPError as exc:
116
+ raise BackendError(f"Windmill is unavailable: {exc}", 502) from exc
117
+ if response.is_error:
118
+ status_code = 404 if response.status_code == 404 else 502
119
+ detail = response.text.strip()[:500] or f"HTTP {response.status_code}"
120
+ raise BackendError(f"Windmill rejected the request: {detail}", status_code)
121
+ return response
122
+
123
+ def health_check(self) -> None:
124
+ if not self.token:
125
+ raise BackendError("WINDMILL_TOKEN is not configured", 503)
126
+ try:
127
+ response = self.client.get(f"{self.base_url}/api/health/status")
128
+ except httpx.TimeoutException as exc:
129
+ raise BackendError("Windmill health check timed out", 504) from exc
130
+ except httpx.HTTPError as exc:
131
+ raise BackendError(f"Windmill is unavailable: {exc}", 502) from exc
132
+ if response.is_error:
133
+ raise BackendError(
134
+ f"Windmill health check failed: HTTP {response.status_code}", 502
135
+ )
136
+ status = response.json().get("status")
137
+ if status not in {"healthy", "ok"}:
138
+ raise BackendError(f"Windmill reports status {status!r}", 502)
139
+
140
+ def _submit_flow(self, value: dict[str, Any], args: dict[str, Any]) -> str:
141
+ response = self._request(
142
+ "POST", "jobs/run/preview_flow", json={"value": value, "args": args}
143
+ )
144
+ return self._response_task_id(response)
145
+
146
+ @staticmethod
147
+ def _response_task_id(response: httpx.Response) -> str:
148
+ lines = response.text.strip().splitlines()
149
+ if not lines:
150
+ raise BackendError("Windmill returned an empty task ID")
151
+ try:
152
+ return str(UUID(lines[0]))
153
+ except ValueError as exc:
154
+ raise BackendError(
155
+ f"Windmill returned an invalid task ID: {lines[0]!r}"
156
+ ) from exc
157
+
158
+ @staticmethod
159
+ def _task_module(task_id: str, task: TaskDefinition) -> dict[str, Any]:
160
+ module: dict[str, Any] = {
161
+ "id": task_id,
162
+ "value": {
163
+ "type": "rawscript",
164
+ "language": _LANGUAGES[task.language],
165
+ "content": task.source,
166
+ "input_transforms": {
167
+ key: {"type": "static", "value": value}
168
+ for key, value in task.args.items()
169
+ },
170
+ },
171
+ }
172
+ if task.retry.attempts:
173
+ module["retry"] = {
174
+ "constant": {
175
+ "attempts": task.retry.attempts,
176
+ "seconds": task.retry.delay_seconds,
177
+ }
178
+ }
179
+ if task.timeout_seconds:
180
+ module["timeout"] = {"type": "static", "value": task.timeout_seconds}
181
+ return module
182
+
183
+ @staticmethod
184
+ def _dependency_module(
185
+ dependencies: list[str], timeout_seconds: int
186
+ ) -> dict[str, Any]:
187
+ # ponytail: polling creates one short Windmill job per interval; replace with
188
+ # backend completion events when dependency volume makes that measurable.
189
+ return {
190
+ "id": "funmill_wait",
191
+ "value": {
192
+ "type": "whileloopflow",
193
+ "skip_failures": False,
194
+ "modules": [
195
+ {
196
+ "id": "funmill_check_dependencies",
197
+ "value": {
198
+ "type": "rawscript",
199
+ "language": "python3",
200
+ "content": _DEPENDENCY_SOURCE,
201
+ "input_transforms": {
202
+ "dependencies": {
203
+ "type": "static",
204
+ "value": dependencies,
205
+ },
206
+ "timeout_seconds": {
207
+ "type": "static",
208
+ "value": timeout_seconds,
209
+ },
210
+ },
211
+ },
212
+ "sleep": {"type": "static", "value": 2},
213
+ "stop_after_if": {"expr": "result === true"},
214
+ }
215
+ ],
216
+ },
217
+ }
218
+
219
+ @staticmethod
220
+ def _add_callback(
221
+ value: dict[str, Any], callback_url: str | None, result_id: str
222
+ ) -> None:
223
+ if callback_url is None:
224
+ return
225
+
226
+ def callback_module(module_id: str, status: str, payload_expr: str):
227
+ return {
228
+ "id": module_id,
229
+ "value": {
230
+ "type": "rawscript",
231
+ "language": "python3",
232
+ "content": _CALLBACK_SOURCE,
233
+ "input_transforms": {
234
+ "callback_url": {"type": "static", "value": callback_url},
235
+ "status": {"type": "static", "value": status},
236
+ "payload": {"type": "javascript", "expr": payload_expr},
237
+ },
238
+ },
239
+ "retry": {"constant": {"attempts": 3, "seconds": 2}},
240
+ }
241
+
242
+ result_expr = f"results[{json.dumps(result_id)}]"
243
+ value["modules"].append(
244
+ callback_module("funmill_callback", "succeeded", result_expr)
245
+ )
246
+ value["failure_module"] = callback_module("failure", "failed", "error")
247
+
248
+ @staticmethod
249
+ def _result_module(task_results: dict[str, str]) -> dict[str, Any]:
250
+ entries = ", ".join(
251
+ f"{json.dumps(task_id)}: {result_expr}"
252
+ for task_id, result_expr in task_results.items()
253
+ )
254
+ return {
255
+ "id": "funmill_result",
256
+ "value": {
257
+ "type": "rawscript",
258
+ "language": "python3",
259
+ "content": _RESULT_SOURCE,
260
+ "input_transforms": {
261
+ "results": {
262
+ "type": "javascript",
263
+ "expr": f"({{{entries}}})",
264
+ }
265
+ },
266
+ },
267
+ }
268
+
269
+ def submit_task(self, task: TaskSubmit) -> str:
270
+ modules = []
271
+ if task.depends_on:
272
+ modules.append(
273
+ self._dependency_module(
274
+ task.depends_on, task.dependency_timeout_seconds
275
+ )
276
+ )
277
+ modules.append(self._task_module("task", task))
278
+ value = {"modules": modules}
279
+ self._add_callback(
280
+ value,
281
+ str(task.callback_url) if task.callback_url else None,
282
+ "task",
283
+ )
284
+ return self._submit_flow(value, {})
285
+
286
+ def submit_workflow(self, workflow: WorkflowSubmit) -> str:
287
+ modules = []
288
+ task_results = {}
289
+ if workflow.depends_on:
290
+ modules.append(
291
+ self._dependency_module(
292
+ workflow.depends_on, workflow.dependency_timeout_seconds
293
+ )
294
+ )
295
+
296
+ for index, layer in enumerate(workflow.topological_layers()):
297
+ if len(layer) == 1:
298
+ module = self._task_module(layer[0].key, layer[0])
299
+ task_results[layer[0].key] = f"results[{json.dumps(layer[0].key)}]"
300
+ else:
301
+ layer_id = f"funmill_layer_{index}"
302
+ module = {
303
+ "id": layer_id,
304
+ "value": {
305
+ "type": "branchall",
306
+ "parallel": True,
307
+ "branches": [
308
+ {
309
+ "summary": task.key,
310
+ "modules": [self._task_module(task.key, task)],
311
+ }
312
+ for task in layer
313
+ ],
314
+ },
315
+ }
316
+ for task_index, task in enumerate(layer):
317
+ task_results[task.key] = (
318
+ f"results[{json.dumps(layer_id)}][{task_index}]"
319
+ )
320
+ modules.append(module)
321
+
322
+ result_module = self._result_module(task_results)
323
+ modules.append(result_module)
324
+
325
+ value = {"modules": modules}
326
+ self._add_callback(
327
+ value,
328
+ str(workflow.callback_url) if workflow.callback_url else None,
329
+ result_module["id"],
330
+ )
331
+ return self._submit_flow(value, {})
332
+
333
+ def _get_job(self, task_id: str) -> dict[str, Any]:
334
+ return self._request("GET", f"jobs_u/get/{task_id}").json()
335
+
336
+ @staticmethod
337
+ def _status(job: dict[str, Any]) -> TaskStatus:
338
+ if job.get("canceled"):
339
+ return TaskStatus.CANCELED
340
+ if "success" in job:
341
+ return TaskStatus.SUCCEEDED if job["success"] else TaskStatus.FAILED
342
+ return TaskStatus.RUNNING if job.get("running") else TaskStatus.QUEUED
343
+
344
+ def get_task(self, task_id: str) -> TaskInfo:
345
+ job = self._get_job(task_id)
346
+ return TaskInfo(
347
+ task_id=task_id,
348
+ status=self._status(job),
349
+ created_at=job.get("created_at"),
350
+ started_at=job.get("started_at"),
351
+ completed_at=job.get("completed_at"),
352
+ duration_ms=job.get("duration_ms"),
353
+ )
354
+
355
+ def get_progress(self, task_id: str) -> TaskProgress:
356
+ job = self._get_job(task_id)
357
+ status = self._status(job)
358
+ if status == TaskStatus.SUCCEEDED:
359
+ return TaskProgress(task_id=task_id, progress=100)
360
+
361
+ modules = (job.get("flow_status") or {}).get("modules") or []
362
+ scores = []
363
+ for module in modules:
364
+ if module.get("type") == "Success":
365
+ scores.append(100)
366
+ elif module.get("type") == "InProgress":
367
+ scores.append(module.get("progress") or 0)
368
+ else:
369
+ scores.append(0)
370
+ progress = int(sum(scores) / len(scores)) if scores else None
371
+ return TaskProgress(task_id=task_id, progress=progress)
372
+
373
+ def get_logs(self, task_id: str) -> TaskLogs:
374
+ job = self._get_job(task_id)
375
+ if job.get("job_kind") in {"flow", "flowpreview", "singlestepflow"}:
376
+ response = self._request("GET", f"jobs_u/get_flow_all_logs/{task_id}")
377
+ logs = response.text
378
+ else:
379
+ logs = self._request("GET", f"jobs_u/get_logs/{task_id}").text
380
+ return TaskLogs(task_id=task_id, logs=logs)
381
+
382
+ def get_result(self, task_id: str) -> TaskResult:
383
+ result = self._request("GET", f"jobs_u/completed/get_result/{task_id}").json()
384
+ return TaskResult(task_id=task_id, result=result)
385
+
386
+ def cancel(self, task_id: str, reason: str) -> None:
387
+ self._request("POST", f"jobs_u/queue/cancel/{task_id}", json={"reason": reason})
388
+
389
+ def rerun(self, task_id: str) -> str:
390
+ job = self._get_job(task_id)
391
+ raw_flow = job.get("raw_flow")
392
+ if raw_flow:
393
+ return self._submit_flow(raw_flow, job.get("args") or {})
394
+
395
+ raw_code = job.get("raw_code")
396
+ if raw_code:
397
+ response = self._request(
398
+ "POST",
399
+ "jobs/run/preview",
400
+ json={
401
+ "content": raw_code,
402
+ "language": job.get("language"),
403
+ "kind": "code",
404
+ "args": job.get("args") or {},
405
+ },
406
+ )
407
+ return self._response_task_id(response)
408
+
409
+ raise BackendError(
410
+ "task cannot be rerun because its source is unavailable", 409
411
+ )
412
+
413
+ def close(self) -> None:
414
+ self.client.close()