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.
- funmill/api/__init__.py +44 -0
- funmill/api/app.py +103 -0
- funmill/api/backends/__init__.py +42 -0
- funmill/api/backends/base.py +51 -0
- funmill/api/backends/dagu/README.md +83 -0
- funmill/api/backends/dagu/__init__.py +458 -0
- funmill/api/backends/dagu/service.py +149 -0
- funmill/api/backends/service.py +107 -0
- funmill/api/backends/windmill/README.md +112 -0
- funmill/api/backends/windmill/__init__.py +414 -0
- funmill/api/backends/windmill/service.py +151 -0
- funmill/api/cli.py +83 -0
- funmill/api/models.py +120 -0
- funmill/api/ports.py +3 -0
- funmill_api-0.1.2.dist-info/METADATA +211 -0
- funmill_api-0.1.2.dist-info/RECORD +18 -0
- funmill_api-0.1.2.dist-info/WHEEL +4 -0
- funmill_api-0.1.2.dist-info/entry_points.txt +2 -0
funmill/api/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from .app import app, backend_dependency
|
|
2
|
+
from .backends import (
|
|
3
|
+
BACKEND_SPECS,
|
|
4
|
+
BackendError,
|
|
5
|
+
TaskBackend,
|
|
6
|
+
get_backend,
|
|
7
|
+
list_backends,
|
|
8
|
+
)
|
|
9
|
+
from .models import (
|
|
10
|
+
CancelRequest,
|
|
11
|
+
RetryPolicy,
|
|
12
|
+
TaskAccepted,
|
|
13
|
+
TaskInfo,
|
|
14
|
+
TaskLanguage,
|
|
15
|
+
TaskLogs,
|
|
16
|
+
TaskProgress,
|
|
17
|
+
TaskResult,
|
|
18
|
+
TaskStatus,
|
|
19
|
+
TaskSubmit,
|
|
20
|
+
WorkflowSubmit,
|
|
21
|
+
WorkflowTask,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"BACKEND_SPECS",
|
|
26
|
+
"BackendError",
|
|
27
|
+
"CancelRequest",
|
|
28
|
+
"RetryPolicy",
|
|
29
|
+
"TaskAccepted",
|
|
30
|
+
"TaskBackend",
|
|
31
|
+
"TaskInfo",
|
|
32
|
+
"TaskLanguage",
|
|
33
|
+
"TaskLogs",
|
|
34
|
+
"TaskProgress",
|
|
35
|
+
"TaskResult",
|
|
36
|
+
"TaskStatus",
|
|
37
|
+
"TaskSubmit",
|
|
38
|
+
"WorkflowSubmit",
|
|
39
|
+
"WorkflowTask",
|
|
40
|
+
"app",
|
|
41
|
+
"backend_dependency",
|
|
42
|
+
"get_backend",
|
|
43
|
+
"list_backends",
|
|
44
|
+
]
|
funmill/api/app.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import secrets
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends, FastAPI
|
|
8
|
+
from fastapi.responses import JSONResponse
|
|
9
|
+
from fastapi.security import APIKeyHeader
|
|
10
|
+
|
|
11
|
+
from funmill.api.backends import BackendError, TaskBackend, get_backend
|
|
12
|
+
from funmill.api.models import (
|
|
13
|
+
CancelRequest,
|
|
14
|
+
TaskAccepted,
|
|
15
|
+
TaskInfo,
|
|
16
|
+
TaskLogs,
|
|
17
|
+
TaskProgress,
|
|
18
|
+
TaskResult,
|
|
19
|
+
TaskSubmit,
|
|
20
|
+
WorkflowSubmit,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def require_api_key(api_key: Annotated[str | None, Depends(api_key_header)]) -> None:
|
|
27
|
+
expected = os.getenv("FUNMILL_API_KEY", "")
|
|
28
|
+
if not expected:
|
|
29
|
+
raise BackendError("FUNMILL_API_KEY is not configured", 503)
|
|
30
|
+
if api_key is None or not secrets.compare_digest(api_key, expected):
|
|
31
|
+
raise BackendError("invalid API key", 401)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@lru_cache
|
|
35
|
+
def backend_dependency() -> TaskBackend:
|
|
36
|
+
return get_backend()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@asynccontextmanager
|
|
40
|
+
async def lifespan(_: FastAPI):
|
|
41
|
+
yield
|
|
42
|
+
if backend_dependency.cache_info().currsize:
|
|
43
|
+
backend_dependency().close()
|
|
44
|
+
backend_dependency.cache_clear()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
app = FastAPI(title="Funmill", version="0.1.0", lifespan=lifespan)
|
|
48
|
+
router = APIRouter(prefix="/v1", dependencies=[Depends(require_api_key)])
|
|
49
|
+
Backend = Annotated[TaskBackend, Depends(backend_dependency)]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.exception_handler(BackendError)
|
|
53
|
+
async def backend_error_handler(_, exc: BackendError):
|
|
54
|
+
return JSONResponse(status_code=exc.status_code, content={"detail": str(exc)})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.get("/health")
|
|
58
|
+
def health(backend: Backend):
|
|
59
|
+
backend.health_check()
|
|
60
|
+
return {"status": "ok", "backend": os.getenv("FUNMILL_BACKEND", "windmill")}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@router.post("/tasks", response_model=TaskAccepted, status_code=202)
|
|
64
|
+
def submit_task(task: TaskSubmit, backend: Backend):
|
|
65
|
+
return TaskAccepted(task_id=backend.submit_task(task))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.post("/workflows", response_model=TaskAccepted, status_code=202)
|
|
69
|
+
def submit_workflow(workflow: WorkflowSubmit, backend: Backend):
|
|
70
|
+
return TaskAccepted(task_id=backend.submit_workflow(workflow))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@router.get("/tasks/{task_id}", response_model=TaskInfo)
|
|
74
|
+
def get_task(task_id: str, backend: Backend):
|
|
75
|
+
return backend.get_task(task_id)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@router.get("/tasks/{task_id}/progress", response_model=TaskProgress)
|
|
79
|
+
def get_progress(task_id: str, backend: Backend):
|
|
80
|
+
return backend.get_progress(task_id)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@router.get("/tasks/{task_id}/logs", response_model=TaskLogs)
|
|
84
|
+
def get_logs(task_id: str, backend: Backend):
|
|
85
|
+
return backend.get_logs(task_id)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@router.get("/tasks/{task_id}/result", response_model=TaskResult)
|
|
89
|
+
def get_result(task_id: str, backend: Backend):
|
|
90
|
+
return backend.get_result(task_id)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@router.post("/tasks/{task_id}/cancel", status_code=204)
|
|
94
|
+
def cancel(task_id: str, request: CancelRequest, backend: Backend) -> None:
|
|
95
|
+
backend.cancel(task_id, request.reason)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@router.post("/tasks/{task_id}/rerun", response_model=TaskAccepted, status_code=202)
|
|
99
|
+
def rerun(task_id: str, backend: Backend):
|
|
100
|
+
return TaskAccepted(task_id=backend.rerun(task_id), rerun_of=task_id)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
app.include_router(router)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any, NamedTuple
|
|
4
|
+
|
|
5
|
+
from .base import BackendError, TaskBackend
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BackendSpec(NamedTuple):
|
|
9
|
+
module: str
|
|
10
|
+
cls: str
|
|
11
|
+
service: str | None = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
BACKEND_SPECS = {
|
|
15
|
+
"dagu": BackendSpec(".dagu", "DaguBackend", ".dagu.service"),
|
|
16
|
+
"windmill": BackendSpec(".windmill", "WindmillBackend", ".windmill.service"),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_backend(backend_type: str | None = None, **kwargs: Any) -> TaskBackend:
|
|
21
|
+
key = (backend_type or os.getenv("FUNMILL_BACKEND", "windmill")).lower()
|
|
22
|
+
spec = BACKEND_SPECS.get(key)
|
|
23
|
+
if spec is None:
|
|
24
|
+
raise ValueError(
|
|
25
|
+
f"unsupported backend {key!r}; available: {', '.join(BACKEND_SPECS)}"
|
|
26
|
+
)
|
|
27
|
+
module = importlib.import_module(spec.module, __name__)
|
|
28
|
+
backend_class = getattr(module, spec.cls)
|
|
29
|
+
return backend_class(**kwargs) if kwargs else backend_class.from_env()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def list_backends() -> list[str]:
|
|
33
|
+
return list(BACKEND_SPECS)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"BACKEND_SPECS",
|
|
38
|
+
"BackendError",
|
|
39
|
+
"TaskBackend",
|
|
40
|
+
"get_backend",
|
|
41
|
+
"list_backends",
|
|
42
|
+
]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
from funmill.api.models import (
|
|
4
|
+
TaskInfo,
|
|
5
|
+
TaskLogs,
|
|
6
|
+
TaskProgress,
|
|
7
|
+
TaskResult,
|
|
8
|
+
TaskSubmit,
|
|
9
|
+
WorkflowSubmit,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BackendError(RuntimeError):
|
|
14
|
+
def __init__(self, message: str, status_code: int = 502) -> None:
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.status_code = status_code
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TaskBackend(ABC):
|
|
20
|
+
name: str
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def health_check(self) -> None:
|
|
24
|
+
"""Raise BackendError if the backend is unreachable or misconfigured."""
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def submit_task(self, task: TaskSubmit) -> str: ...
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def submit_workflow(self, workflow: WorkflowSubmit) -> str: ...
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def get_task(self, task_id: str) -> TaskInfo: ...
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def get_progress(self, task_id: str) -> TaskProgress: ...
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def get_logs(self, task_id: str) -> TaskLogs: ...
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def get_result(self, task_id: str) -> TaskResult: ...
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def cancel(self, task_id: str, reason: str) -> None: ...
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def rerun(self, task_id: str) -> str: ...
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def close(self) -> None: ...
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Dagu 简单部署
|
|
2
|
+
|
|
3
|
+
以下方式不使用 Docker,适用于 macOS 和 Linux 的 x86_64、ARM64 平台。
|
|
4
|
+
Funmill 当前固定安装 Dagu `v2.16.3`,运行任务还需要 `python3` 和 Bash。
|
|
5
|
+
|
|
6
|
+
## 1. 安装 Funmill 和 Dagu
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
uv sync
|
|
10
|
+
uv run funmill install dagu
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Dagu 二进制会放在 `~/.farfarfun/funmill/services/dagu/dagu`,运行数据会放在
|
|
14
|
+
同目录的 `data/`。安装器会分别校验官方发行包和二进制的 SHA-256;设置
|
|
15
|
+
`FUNMILL_HOME` 可以修改 Funmill 的数据根目录。
|
|
16
|
+
|
|
17
|
+
如果已有文件未通过校验,可明确覆盖安装:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
uv run funmill install dagu --force
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 2. 启动 Dagu
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uv run funmill start dagu
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
该命令会在后台启动 Dagu,并打印 PID 和日志路径。PID 与日志分别保存在
|
|
30
|
+
`~/.farfarfun/funmill/services/dagu/dagu.pid` 和 `dagu.log`。
|
|
31
|
+
|
|
32
|
+
Dagu 的 Web 界面和原生 API 固定监听 `0.0.0.0:8813`;本机仍使用
|
|
33
|
+
<http://127.0.0.1:8813> 访问。`0.0.0.0` 是监听地址,不能作为客户端地址。
|
|
34
|
+
本地启动默认设置 `DAGU_AUTH_MODE=none`,不需要外部数据库;未使用的 Dagu
|
|
35
|
+
coordinator 默认关闭。
|
|
36
|
+
|
|
37
|
+
## 3. 启动 Funmill API
|
|
38
|
+
|
|
39
|
+
在另一个终端执行:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
FUNMILL_API_KEY='自行设置的接口密钥' \
|
|
43
|
+
FUNMILL_BACKEND=dagu \
|
|
44
|
+
DAGU_URL='http://127.0.0.1:8813' \
|
|
45
|
+
uv run funmill start
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Funmill API 固定监听 `0.0.0.0:8812`,并保持前台运行。
|
|
49
|
+
`DAGU_TIMEOUT` 可以修改 Funmill 请求 Dagu 的超时秒数,默认值为 `30`。
|
|
50
|
+
若自行启用了 Dagu 认证,两个启动命令都需要设置相同的 `DAGU_TOKEN`;
|
|
51
|
+
Funmill 会将其作为 Bearer Token 使用,并传给等待跨任务依赖的 Dagu 任务。
|
|
52
|
+
|
|
53
|
+
## 4. 验证
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
curl http://127.0.0.1:8812/health
|
|
57
|
+
FUNMILL_API_KEY='自行设置的接口密钥' ./scripts/smoke.sh
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
smoke 脚本会验证 Python、Bash 和并行 DAG 的提交、状态、进度、日志与结果。
|
|
61
|
+
设置 `FUNMILL_CALLBACK_URL` 还会验证回调;该地址必须能从 Dagu 任务进程访问。
|
|
62
|
+
|
|
63
|
+
## 5. 管理后台服务
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
uv run funmill status dagu
|
|
67
|
+
uv run funmill restart dagu
|
|
68
|
+
uv run funmill stop dagu
|
|
69
|
+
tail -f ~/.farfarfun/funmill/services/dagu/dagu.log
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
重复启动会被 PID 文件拦截。`stop` 会向 Dagu 的独立进程组发送 `SIGTERM`,
|
|
73
|
+
等待正常退出后删除 PID 文件;服务异常退出后,`status` 会清理失效 PID。
|
|
74
|
+
|
|
75
|
+
## 安全与长期运行
|
|
76
|
+
|
|
77
|
+
Dagu 默认无认证且监听所有网络接口,必须使用防火墙限制 `8813`,或启用 Dagu
|
|
78
|
+
认证并配置 `DAGU_TOKEN`。Dagu 会以启动服务的宿主机用户权限直接执行
|
|
79
|
+
Funmill 提交的源码,只应接收可信任务。接受不可信代码前必须使用隔离 Worker
|
|
80
|
+
或容器,不要仅依赖 Funmill 的 API Key。
|
|
81
|
+
|
|
82
|
+
Funmill 自带的后台模式适合简单部署。需要开机启动、自动重启和日志轮转时,
|
|
83
|
+
使用现有的 launchd、systemd 或进程管理器直接管理已安装的 Dagu 二进制。
|