funmill 0.1.1__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/__init__.py +41 -0
- funmill/api.py +102 -0
- funmill/backends/__init__.py +41 -0
- funmill/backends/base.py +47 -0
- funmill/backends/windmill/README.md +87 -0
- funmill/backends/windmill/__init__.py +393 -0
- funmill/backends/windmill/service.py +76 -0
- funmill/cli.py +63 -0
- funmill/models.py +120 -0
- funmill-0.1.1.dist-info/METADATA +149 -0
- funmill-0.1.1.dist-info/RECORD +13 -0
- funmill-0.1.1.dist-info/WHEEL +4 -0
- funmill-0.1.1.dist-info/entry_points.txt +2 -0
funmill/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from .backends import (
|
|
2
|
+
BACKEND_SPECS,
|
|
3
|
+
BackendError,
|
|
4
|
+
TaskBackend,
|
|
5
|
+
get_backend,
|
|
6
|
+
list_backends,
|
|
7
|
+
)
|
|
8
|
+
from .models import (
|
|
9
|
+
CancelRequest,
|
|
10
|
+
RetryPolicy,
|
|
11
|
+
TaskAccepted,
|
|
12
|
+
TaskInfo,
|
|
13
|
+
TaskLanguage,
|
|
14
|
+
TaskLogs,
|
|
15
|
+
TaskProgress,
|
|
16
|
+
TaskResult,
|
|
17
|
+
TaskStatus,
|
|
18
|
+
TaskSubmit,
|
|
19
|
+
WorkflowSubmit,
|
|
20
|
+
WorkflowTask,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"BACKEND_SPECS",
|
|
25
|
+
"BackendError",
|
|
26
|
+
"CancelRequest",
|
|
27
|
+
"RetryPolicy",
|
|
28
|
+
"TaskAccepted",
|
|
29
|
+
"TaskBackend",
|
|
30
|
+
"TaskInfo",
|
|
31
|
+
"TaskLanguage",
|
|
32
|
+
"TaskLogs",
|
|
33
|
+
"TaskProgress",
|
|
34
|
+
"TaskResult",
|
|
35
|
+
"TaskStatus",
|
|
36
|
+
"TaskSubmit",
|
|
37
|
+
"WorkflowSubmit",
|
|
38
|
+
"WorkflowTask",
|
|
39
|
+
"get_backend",
|
|
40
|
+
"list_backends",
|
|
41
|
+
]
|
funmill/api.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
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.backends import BackendError, TaskBackend, get_backend
|
|
12
|
+
from funmill.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():
|
|
59
|
+
return {"status": "ok", "backend": os.getenv("FUNMILL_BACKEND", "windmill")}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@router.post("/tasks", response_model=TaskAccepted, status_code=202)
|
|
63
|
+
def submit_task(task: TaskSubmit, backend: Backend):
|
|
64
|
+
return TaskAccepted(task_id=backend.submit_task(task))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@router.post("/workflows", response_model=TaskAccepted, status_code=202)
|
|
68
|
+
def submit_workflow(workflow: WorkflowSubmit, backend: Backend):
|
|
69
|
+
return TaskAccepted(task_id=backend.submit_workflow(workflow))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@router.get("/tasks/{task_id}", response_model=TaskInfo)
|
|
73
|
+
def get_task(task_id: str, backend: Backend):
|
|
74
|
+
return backend.get_task(task_id)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@router.get("/tasks/{task_id}/progress", response_model=TaskProgress)
|
|
78
|
+
def get_progress(task_id: str, backend: Backend):
|
|
79
|
+
return backend.get_progress(task_id)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@router.get("/tasks/{task_id}/logs", response_model=TaskLogs)
|
|
83
|
+
def get_logs(task_id: str, backend: Backend):
|
|
84
|
+
return backend.get_logs(task_id)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@router.get("/tasks/{task_id}/result", response_model=TaskResult)
|
|
88
|
+
def get_result(task_id: str, backend: Backend):
|
|
89
|
+
return backend.get_result(task_id)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@router.post("/tasks/{task_id}/cancel", status_code=204)
|
|
93
|
+
def cancel(task_id: str, request: CancelRequest, backend: Backend) -> None:
|
|
94
|
+
backend.cancel(task_id, request.reason)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@router.post("/tasks/{task_id}/rerun", response_model=TaskAccepted, status_code=202)
|
|
98
|
+
def rerun(task_id: str, backend: Backend):
|
|
99
|
+
return TaskAccepted(task_id=backend.rerun(task_id), rerun_of=task_id)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
app.include_router(router)
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
"windmill": BackendSpec(".windmill", "WindmillBackend", ".windmill.service"),
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_backend(backend_type: str | None = None, **kwargs: Any) -> TaskBackend:
|
|
20
|
+
key = (backend_type or os.getenv("FUNMILL_BACKEND", "windmill")).lower()
|
|
21
|
+
spec = BACKEND_SPECS.get(key)
|
|
22
|
+
if spec is None:
|
|
23
|
+
raise ValueError(
|
|
24
|
+
f"unsupported backend {key!r}; available: {', '.join(BACKEND_SPECS)}"
|
|
25
|
+
)
|
|
26
|
+
module = importlib.import_module(spec.module, __name__)
|
|
27
|
+
backend_class = getattr(module, spec.cls)
|
|
28
|
+
return backend_class(**kwargs) if kwargs else backend_class.from_env()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def list_backends() -> list[str]:
|
|
32
|
+
return list(BACKEND_SPECS)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"BACKEND_SPECS",
|
|
37
|
+
"BackendError",
|
|
38
|
+
"TaskBackend",
|
|
39
|
+
"get_backend",
|
|
40
|
+
"list_backends",
|
|
41
|
+
]
|
funmill/backends/base.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
from funmill.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 submit_task(self, task: TaskSubmit) -> str: ...
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def submit_workflow(self, workflow: WorkflowSubmit) -> str: ...
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def get_task(self, task_id: str) -> TaskInfo: ...
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def get_progress(self, task_id: str) -> TaskProgress: ...
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def get_logs(self, task_id: str) -> TaskLogs: ...
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def get_result(self, task_id: str) -> TaskResult: ...
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def cancel(self, task_id: str, reason: str) -> None: ...
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def rerun(self, task_id: str) -> str: ...
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def close(self) -> None: ...
|
|
@@ -0,0 +1,87 @@
|
|
|
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 会安装到 `~/.farfarfun/funmill/services/windmill/`。安装器会校验
|
|
14
|
+
官方发行包的 SHA-256;设置 `FUNMILL_HOME` 可以修改安装目录。
|
|
15
|
+
|
|
16
|
+
## 2. 准备数据库
|
|
17
|
+
|
|
18
|
+
在 PostgreSQL 中创建用户和数据库:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
sudo -u postgres createuser --pwprompt windmill
|
|
22
|
+
sudo -u postgres createdb --owner=windmill windmill
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
然后确认下面的连接可以使用:
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
postgres://windmill:数据库密码@127.0.0.1:5432/windmill
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## 3. 启动 Windmill
|
|
32
|
+
|
|
33
|
+
`standalone` 模式会在一个进程中同时运行 Server 和一个 Worker:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
DATABASE_URL='postgres://windmill:数据库密码@127.0.0.1:5432/windmill' \
|
|
37
|
+
MODE=standalone \
|
|
38
|
+
PORT=8001 \
|
|
39
|
+
BASE_URL='http://127.0.0.1:8001' \
|
|
40
|
+
SERVER_BIND_ADDR=127.0.0.1 \
|
|
41
|
+
uv run funmill start windmill
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
打开 <http://127.0.0.1:8001>,首次登录使用:
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
admin@windmill.dev / changeme
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
立即修改密码,并在 `admins` workspace 中创建 API Token。
|
|
51
|
+
|
|
52
|
+
## 4. 启动 Funmill API
|
|
53
|
+
|
|
54
|
+
回到 Funmill 仓库根目录执行:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
FUNMILL_API_KEY='自行设置的接口密钥' \
|
|
58
|
+
FUNMILL_BACKEND=windmill \
|
|
59
|
+
WINDMILL_URL='http://127.0.0.1:8001' \
|
|
60
|
+
WINDMILL_WORKSPACE=admins \
|
|
61
|
+
WINDMILL_TOKEN='刚创建的Windmill-Token' \
|
|
62
|
+
uv run funmill start
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
验证:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
curl http://127.0.0.1:8000/health
|
|
69
|
+
FUNMILL_API_KEY='自行设置的接口密钥' ./scripts/smoke.sh
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## 增加 Worker
|
|
73
|
+
|
|
74
|
+
需要更高并发时,在同一台或其他机器额外启动 Worker。每个进程使用不同的
|
|
75
|
+
`WORKER_SUFFIX`:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
DATABASE_URL='postgres://windmill:数据库密码@127.0.0.1:5432/windmill' \
|
|
79
|
+
MODE=worker WORKER_SUFFIX=worker2 uv run funmill start windmill
|
|
80
|
+
|
|
81
|
+
DATABASE_URL='postgres://windmill:数据库密码@127.0.0.1:5432/windmill' \
|
|
82
|
+
MODE=worker WORKER_SUFFIX=worker3 uv run funmill start windmill
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
正式长期运行时,将上述命令交给现有的 systemd 或进程管理器即可。不要在
|
|
86
|
+
一个普通 Worker 进程中设置 `NUM_WORKERS>1`;Windmill 会因为隔离安全限制将
|
|
87
|
+
它回退为 1,多个独立 Worker 进程更明确。
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from funmill.models import (
|
|
9
|
+
TaskDefinition,
|
|
10
|
+
TaskInfo,
|
|
11
|
+
TaskLanguage,
|
|
12
|
+
TaskLogs,
|
|
13
|
+
TaskProgress,
|
|
14
|
+
TaskResult,
|
|
15
|
+
TaskStatus,
|
|
16
|
+
TaskSubmit,
|
|
17
|
+
WorkflowSubmit,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from ..base import BackendError, TaskBackend
|
|
21
|
+
|
|
22
|
+
_CALLBACK_SOURCE = """import json
|
|
23
|
+
import os
|
|
24
|
+
from urllib.request import Request, urlopen
|
|
25
|
+
|
|
26
|
+
def main(callback_url: str, status: str, payload):
|
|
27
|
+
body = {
|
|
28
|
+
"task_id": os.environ["WM_ROOT_JOB_ID"],
|
|
29
|
+
"status": status,
|
|
30
|
+
"payload": payload,
|
|
31
|
+
}
|
|
32
|
+
request = Request(
|
|
33
|
+
callback_url,
|
|
34
|
+
data=json.dumps(body, default=str).encode(),
|
|
35
|
+
headers={"Content-Type": "application/json"},
|
|
36
|
+
method="POST",
|
|
37
|
+
)
|
|
38
|
+
with urlopen(request, timeout=10):
|
|
39
|
+
return payload
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
_DEPENDENCY_SOURCE = """from datetime import datetime, timezone
|
|
43
|
+
import os
|
|
44
|
+
from wmill import get_job
|
|
45
|
+
|
|
46
|
+
def main(dependencies: list[str], timeout_seconds: int):
|
|
47
|
+
root = get_job(os.environ["WM_ROOT_JOB_ID"])
|
|
48
|
+
created_at = datetime.fromisoformat(root["created_at"].replace("Z", "+00:00"))
|
|
49
|
+
if (datetime.now(timezone.utc) - created_at).total_seconds() > timeout_seconds:
|
|
50
|
+
raise TimeoutError("dependency wait timed out")
|
|
51
|
+
|
|
52
|
+
for task_id in dependencies:
|
|
53
|
+
job = get_job(task_id)
|
|
54
|
+
if "success" not in job:
|
|
55
|
+
return False
|
|
56
|
+
if job.get("canceled"):
|
|
57
|
+
raise RuntimeError(f"dependency {task_id} was canceled")
|
|
58
|
+
if not job["success"]:
|
|
59
|
+
raise RuntimeError(f"dependency {task_id} failed")
|
|
60
|
+
return True
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
_RESULT_SOURCE = """def main(results):
|
|
64
|
+
return results
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
_LANGUAGES = {
|
|
68
|
+
TaskLanguage.PYTHON: "python3",
|
|
69
|
+
TaskLanguage.BASH: "bash",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class WindmillBackend(TaskBackend):
|
|
74
|
+
name = "windmill"
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
base_url: str,
|
|
79
|
+
workspace: str,
|
|
80
|
+
token: str,
|
|
81
|
+
timeout: float = 30,
|
|
82
|
+
client: httpx.Client | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
self.token = token
|
|
85
|
+
self.client = client or httpx.Client(
|
|
86
|
+
base_url=f"{base_url.rstrip('/')}/api/w/{workspace}/",
|
|
87
|
+
timeout=timeout,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_env(cls) -> "WindmillBackend":
|
|
92
|
+
return cls(
|
|
93
|
+
base_url=os.getenv("WINDMILL_URL", "http://windmill_server:8000"),
|
|
94
|
+
workspace=os.getenv("WINDMILL_WORKSPACE", "admins"),
|
|
95
|
+
token=os.getenv("WINDMILL_TOKEN", ""),
|
|
96
|
+
timeout=float(os.getenv("WINDMILL_TIMEOUT", "30")),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
100
|
+
if not self.token:
|
|
101
|
+
raise BackendError("WINDMILL_TOKEN is not configured", 503)
|
|
102
|
+
try:
|
|
103
|
+
response = self.client.request(
|
|
104
|
+
method,
|
|
105
|
+
path,
|
|
106
|
+
headers={"Authorization": f"Bearer {self.token}"},
|
|
107
|
+
**kwargs,
|
|
108
|
+
)
|
|
109
|
+
except httpx.TimeoutException as exc:
|
|
110
|
+
raise BackendError("Windmill request timed out", 504) from exc
|
|
111
|
+
except httpx.HTTPError as exc:
|
|
112
|
+
raise BackendError(f"Windmill is unavailable: {exc}", 502) from exc
|
|
113
|
+
if response.is_error:
|
|
114
|
+
status_code = 404 if response.status_code == 404 else 502
|
|
115
|
+
detail = response.text.strip()[:500] or f"HTTP {response.status_code}"
|
|
116
|
+
raise BackendError(f"Windmill rejected the request: {detail}", status_code)
|
|
117
|
+
return response
|
|
118
|
+
|
|
119
|
+
def _submit_flow(self, value: dict[str, Any], args: dict[str, Any]) -> str:
|
|
120
|
+
response = self._request(
|
|
121
|
+
"POST", "jobs/run/preview_flow", json={"value": value, "args": args}
|
|
122
|
+
)
|
|
123
|
+
return self._response_task_id(response)
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def _response_task_id(response: httpx.Response) -> str:
|
|
127
|
+
lines = response.text.strip().splitlines()
|
|
128
|
+
if not lines:
|
|
129
|
+
raise BackendError("Windmill returned an empty task ID")
|
|
130
|
+
try:
|
|
131
|
+
return str(UUID(lines[0]))
|
|
132
|
+
except ValueError as exc:
|
|
133
|
+
raise BackendError(
|
|
134
|
+
f"Windmill returned an invalid task ID: {lines[0]!r}"
|
|
135
|
+
) from exc
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _task_module(task_id: str, task: TaskDefinition) -> dict[str, Any]:
|
|
139
|
+
module: dict[str, Any] = {
|
|
140
|
+
"id": task_id,
|
|
141
|
+
"value": {
|
|
142
|
+
"type": "rawscript",
|
|
143
|
+
"language": _LANGUAGES[task.language],
|
|
144
|
+
"content": task.source,
|
|
145
|
+
"input_transforms": {
|
|
146
|
+
key: {"type": "static", "value": value}
|
|
147
|
+
for key, value in task.args.items()
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
if task.retry.attempts:
|
|
152
|
+
module["retry"] = {
|
|
153
|
+
"constant": {
|
|
154
|
+
"attempts": task.retry.attempts,
|
|
155
|
+
"seconds": task.retry.delay_seconds,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if task.timeout_seconds:
|
|
159
|
+
module["timeout"] = {"type": "static", "value": task.timeout_seconds}
|
|
160
|
+
return module
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _dependency_module(
|
|
164
|
+
dependencies: list[str], timeout_seconds: int
|
|
165
|
+
) -> dict[str, Any]:
|
|
166
|
+
# ponytail: polling creates one short Windmill job per interval; replace with
|
|
167
|
+
# backend completion events when dependency volume makes that measurable.
|
|
168
|
+
return {
|
|
169
|
+
"id": "funmill_wait",
|
|
170
|
+
"value": {
|
|
171
|
+
"type": "whileloopflow",
|
|
172
|
+
"skip_failures": False,
|
|
173
|
+
"modules": [
|
|
174
|
+
{
|
|
175
|
+
"id": "funmill_check_dependencies",
|
|
176
|
+
"value": {
|
|
177
|
+
"type": "rawscript",
|
|
178
|
+
"language": "python3",
|
|
179
|
+
"content": _DEPENDENCY_SOURCE,
|
|
180
|
+
"input_transforms": {
|
|
181
|
+
"dependencies": {
|
|
182
|
+
"type": "static",
|
|
183
|
+
"value": dependencies,
|
|
184
|
+
},
|
|
185
|
+
"timeout_seconds": {
|
|
186
|
+
"type": "static",
|
|
187
|
+
"value": timeout_seconds,
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
"sleep": {"type": "static", "value": 2},
|
|
192
|
+
"stop_after_if": {"expr": "result === true"},
|
|
193
|
+
}
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
@staticmethod
|
|
199
|
+
def _add_callback(
|
|
200
|
+
value: dict[str, Any], callback_url: str | None, result_id: str
|
|
201
|
+
) -> None:
|
|
202
|
+
if callback_url is None:
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
def callback_module(module_id: str, status: str, payload_expr: str):
|
|
206
|
+
return {
|
|
207
|
+
"id": module_id,
|
|
208
|
+
"value": {
|
|
209
|
+
"type": "rawscript",
|
|
210
|
+
"language": "python3",
|
|
211
|
+
"content": _CALLBACK_SOURCE,
|
|
212
|
+
"input_transforms": {
|
|
213
|
+
"callback_url": {"type": "static", "value": callback_url},
|
|
214
|
+
"status": {"type": "static", "value": status},
|
|
215
|
+
"payload": {"type": "javascript", "expr": payload_expr},
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
"retry": {"constant": {"attempts": 3, "seconds": 2}},
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
result_expr = f"results[{json.dumps(result_id)}]"
|
|
222
|
+
value["modules"].append(
|
|
223
|
+
callback_module("funmill_callback", "succeeded", result_expr)
|
|
224
|
+
)
|
|
225
|
+
value["failure_module"] = callback_module("failure", "failed", "error")
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
def _result_module(task_results: dict[str, str]) -> dict[str, Any]:
|
|
229
|
+
entries = ", ".join(
|
|
230
|
+
f"{json.dumps(task_id)}: {result_expr}"
|
|
231
|
+
for task_id, result_expr in task_results.items()
|
|
232
|
+
)
|
|
233
|
+
return {
|
|
234
|
+
"id": "funmill_result",
|
|
235
|
+
"value": {
|
|
236
|
+
"type": "rawscript",
|
|
237
|
+
"language": "python3",
|
|
238
|
+
"content": _RESULT_SOURCE,
|
|
239
|
+
"input_transforms": {
|
|
240
|
+
"results": {
|
|
241
|
+
"type": "javascript",
|
|
242
|
+
"expr": f"({{{entries}}})",
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
def submit_task(self, task: TaskSubmit) -> str:
|
|
249
|
+
modules = []
|
|
250
|
+
if task.depends_on:
|
|
251
|
+
modules.append(
|
|
252
|
+
self._dependency_module(
|
|
253
|
+
task.depends_on, task.dependency_timeout_seconds
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
modules.append(self._task_module("task", task))
|
|
257
|
+
value = {"modules": modules}
|
|
258
|
+
self._add_callback(
|
|
259
|
+
value,
|
|
260
|
+
str(task.callback_url) if task.callback_url else None,
|
|
261
|
+
"task",
|
|
262
|
+
)
|
|
263
|
+
return self._submit_flow(value, {})
|
|
264
|
+
|
|
265
|
+
def submit_workflow(self, workflow: WorkflowSubmit) -> str:
|
|
266
|
+
modules = []
|
|
267
|
+
task_results = {}
|
|
268
|
+
if workflow.depends_on:
|
|
269
|
+
modules.append(
|
|
270
|
+
self._dependency_module(
|
|
271
|
+
workflow.depends_on, workflow.dependency_timeout_seconds
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
for index, layer in enumerate(workflow.topological_layers()):
|
|
276
|
+
if len(layer) == 1:
|
|
277
|
+
module = self._task_module(layer[0].key, layer[0])
|
|
278
|
+
task_results[layer[0].key] = f"results[{json.dumps(layer[0].key)}]"
|
|
279
|
+
else:
|
|
280
|
+
layer_id = f"funmill_layer_{index}"
|
|
281
|
+
module = {
|
|
282
|
+
"id": layer_id,
|
|
283
|
+
"value": {
|
|
284
|
+
"type": "branchall",
|
|
285
|
+
"parallel": True,
|
|
286
|
+
"branches": [
|
|
287
|
+
{
|
|
288
|
+
"summary": task.key,
|
|
289
|
+
"modules": [self._task_module(task.key, task)],
|
|
290
|
+
}
|
|
291
|
+
for task in layer
|
|
292
|
+
],
|
|
293
|
+
},
|
|
294
|
+
}
|
|
295
|
+
for task_index, task in enumerate(layer):
|
|
296
|
+
task_results[task.key] = (
|
|
297
|
+
f"results[{json.dumps(layer_id)}][{task_index}]"
|
|
298
|
+
)
|
|
299
|
+
modules.append(module)
|
|
300
|
+
|
|
301
|
+
result_module = self._result_module(task_results)
|
|
302
|
+
modules.append(result_module)
|
|
303
|
+
|
|
304
|
+
value = {"modules": modules}
|
|
305
|
+
self._add_callback(
|
|
306
|
+
value,
|
|
307
|
+
str(workflow.callback_url) if workflow.callback_url else None,
|
|
308
|
+
result_module["id"],
|
|
309
|
+
)
|
|
310
|
+
return self._submit_flow(value, {})
|
|
311
|
+
|
|
312
|
+
def _get_job(self, task_id: str) -> dict[str, Any]:
|
|
313
|
+
return self._request("GET", f"jobs_u/get/{task_id}").json()
|
|
314
|
+
|
|
315
|
+
@staticmethod
|
|
316
|
+
def _status(job: dict[str, Any]) -> TaskStatus:
|
|
317
|
+
if job.get("canceled"):
|
|
318
|
+
return TaskStatus.CANCELED
|
|
319
|
+
if "success" in job:
|
|
320
|
+
return TaskStatus.SUCCEEDED if job["success"] else TaskStatus.FAILED
|
|
321
|
+
return TaskStatus.RUNNING if job.get("running") else TaskStatus.QUEUED
|
|
322
|
+
|
|
323
|
+
def get_task(self, task_id: str) -> TaskInfo:
|
|
324
|
+
job = self._get_job(task_id)
|
|
325
|
+
return TaskInfo(
|
|
326
|
+
task_id=task_id,
|
|
327
|
+
status=self._status(job),
|
|
328
|
+
created_at=job.get("created_at"),
|
|
329
|
+
started_at=job.get("started_at"),
|
|
330
|
+
completed_at=job.get("completed_at"),
|
|
331
|
+
duration_ms=job.get("duration_ms"),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def get_progress(self, task_id: str) -> TaskProgress:
|
|
335
|
+
job = self._get_job(task_id)
|
|
336
|
+
status = self._status(job)
|
|
337
|
+
if status == TaskStatus.SUCCEEDED:
|
|
338
|
+
return TaskProgress(task_id=task_id, progress=100)
|
|
339
|
+
|
|
340
|
+
modules = (job.get("flow_status") or {}).get("modules") or []
|
|
341
|
+
scores = []
|
|
342
|
+
for module in modules:
|
|
343
|
+
if module.get("type") == "Success":
|
|
344
|
+
scores.append(100)
|
|
345
|
+
elif module.get("type") == "InProgress":
|
|
346
|
+
scores.append(module.get("progress") or 0)
|
|
347
|
+
else:
|
|
348
|
+
scores.append(0)
|
|
349
|
+
progress = int(sum(scores) / len(scores)) if scores else None
|
|
350
|
+
return TaskProgress(task_id=task_id, progress=progress)
|
|
351
|
+
|
|
352
|
+
def get_logs(self, task_id: str) -> TaskLogs:
|
|
353
|
+
job = self._get_job(task_id)
|
|
354
|
+
if job.get("job_kind") in {"flow", "flowpreview", "singlestepflow"}:
|
|
355
|
+
response = self._request("GET", f"jobs_u/get_flow_all_logs/{task_id}")
|
|
356
|
+
logs = response.text
|
|
357
|
+
else:
|
|
358
|
+
logs = self._request("GET", f"jobs_u/get_logs/{task_id}").text
|
|
359
|
+
return TaskLogs(task_id=task_id, logs=logs)
|
|
360
|
+
|
|
361
|
+
def get_result(self, task_id: str) -> TaskResult:
|
|
362
|
+
result = self._request("GET", f"jobs_u/completed/get_result/{task_id}").json()
|
|
363
|
+
return TaskResult(task_id=task_id, result=result)
|
|
364
|
+
|
|
365
|
+
def cancel(self, task_id: str, reason: str) -> None:
|
|
366
|
+
self._request("POST", f"jobs_u/queue/cancel/{task_id}", json={"reason": reason})
|
|
367
|
+
|
|
368
|
+
def rerun(self, task_id: str) -> str:
|
|
369
|
+
job = self._get_job(task_id)
|
|
370
|
+
raw_flow = job.get("raw_flow")
|
|
371
|
+
if raw_flow:
|
|
372
|
+
return self._submit_flow(raw_flow, job.get("args") or {})
|
|
373
|
+
|
|
374
|
+
raw_code = job.get("raw_code")
|
|
375
|
+
if raw_code:
|
|
376
|
+
response = self._request(
|
|
377
|
+
"POST",
|
|
378
|
+
"jobs/run/preview",
|
|
379
|
+
json={
|
|
380
|
+
"content": raw_code,
|
|
381
|
+
"language": job.get("language"),
|
|
382
|
+
"kind": "code",
|
|
383
|
+
"args": job.get("args") or {},
|
|
384
|
+
},
|
|
385
|
+
)
|
|
386
|
+
return self._response_task_id(response)
|
|
387
|
+
|
|
388
|
+
raise BackendError(
|
|
389
|
+
"task cannot be rerun because its source is unavailable", 409
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
def close(self) -> None:
|
|
393
|
+
self.client.close()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
import shutil
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from urllib.request import Request, urlopen
|
|
8
|
+
|
|
9
|
+
VERSION = "v1.808.0"
|
|
10
|
+
URL = f"https://github.com/windmill-labs/windmill/releases/download/{VERSION}/windmill-amd64"
|
|
11
|
+
SHA256 = "ed48bfb9a391daa437f0c867376f009c7186855530de7fe2f58cf557ff1f7c3a"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _target() -> Path:
|
|
15
|
+
home = Path(os.getenv("FUNMILL_HOME", Path.home() / ".farfarfun" / "funmill"))
|
|
16
|
+
return home / "services" / "windmill" / "windmill"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _sha256(path: Path) -> str:
|
|
20
|
+
with path.open("rb") as file:
|
|
21
|
+
return hashlib.file_digest(file, "sha256").hexdigest()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def install(force: bool = False) -> Path:
|
|
25
|
+
if platform.system() != "Linux" or platform.machine().lower() not in {
|
|
26
|
+
"x86_64",
|
|
27
|
+
"amd64",
|
|
28
|
+
}:
|
|
29
|
+
raise RuntimeError("Windmill v1.808.0 installer only supports Linux x86_64")
|
|
30
|
+
|
|
31
|
+
target = _target()
|
|
32
|
+
if target.exists() and _sha256(target) == SHA256:
|
|
33
|
+
return target
|
|
34
|
+
if target.exists() and not force:
|
|
35
|
+
raise RuntimeError(
|
|
36
|
+
f"{target} exists but has an unexpected checksum; use --force"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
descriptor, temporary_name = tempfile.mkstemp(dir=target.parent)
|
|
41
|
+
os.close(descriptor)
|
|
42
|
+
temporary = Path(temporary_name)
|
|
43
|
+
try:
|
|
44
|
+
print(f"downloading Windmill {VERSION}...", flush=True)
|
|
45
|
+
request = Request(URL, headers={"User-Agent": "funmill"})
|
|
46
|
+
with urlopen(request, timeout=30) as response, temporary.open("wb") as file:
|
|
47
|
+
shutil.copyfileobj(response, file)
|
|
48
|
+
if _sha256(temporary) != SHA256:
|
|
49
|
+
raise RuntimeError("downloaded Windmill binary failed SHA-256 verification")
|
|
50
|
+
temporary.chmod(0o755)
|
|
51
|
+
temporary.replace(target)
|
|
52
|
+
finally:
|
|
53
|
+
temporary.unlink(missing_ok=True)
|
|
54
|
+
return target
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def start() -> None:
|
|
58
|
+
executable = _target()
|
|
59
|
+
if not executable.exists():
|
|
60
|
+
system_executable = shutil.which("windmill")
|
|
61
|
+
if system_executable is None:
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
"Windmill is not installed; run: funmill install windmill"
|
|
64
|
+
)
|
|
65
|
+
executable = Path(system_executable)
|
|
66
|
+
|
|
67
|
+
if not os.getenv("DATABASE_URL"):
|
|
68
|
+
raise RuntimeError("DATABASE_URL is required")
|
|
69
|
+
|
|
70
|
+
environment = os.environ.copy()
|
|
71
|
+
mode = environment.setdefault("MODE", "standalone")
|
|
72
|
+
if mode in {"standalone", "server"}:
|
|
73
|
+
environment.setdefault("PORT", "8001")
|
|
74
|
+
environment.setdefault("BASE_URL", "http://127.0.0.1:8001")
|
|
75
|
+
environment.setdefault("SERVER_BIND_ADDR", "127.0.0.1")
|
|
76
|
+
os.execve(executable, [str(executable)], environment)
|
funmill/cli.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import importlib
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from types import ModuleType
|
|
6
|
+
|
|
7
|
+
import uvicorn
|
|
8
|
+
|
|
9
|
+
from funmill.backends import BACKEND_SPECS
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _service_names() -> list[str]:
|
|
13
|
+
return [name for name, spec in BACKEND_SPECS.items() if spec.service]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _service(name: str) -> ModuleType:
|
|
17
|
+
spec = BACKEND_SPECS[name]
|
|
18
|
+
return importlib.import_module(spec.service, "funmill.backends")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parser() -> argparse.ArgumentParser:
|
|
22
|
+
parser = argparse.ArgumentParser(prog="funmill")
|
|
23
|
+
commands = parser.add_subparsers(dest="command")
|
|
24
|
+
|
|
25
|
+
commands.add_parser("services", help="列出可安装的第三方服务")
|
|
26
|
+
|
|
27
|
+
install = commands.add_parser("install", help="安装第三方服务")
|
|
28
|
+
install.add_argument("service", choices=_service_names())
|
|
29
|
+
install.add_argument("--force", action="store_true", help="覆盖现有安装")
|
|
30
|
+
|
|
31
|
+
start = commands.add_parser("start", help="启动 Funmill 或第三方服务")
|
|
32
|
+
start.add_argument(
|
|
33
|
+
"service", nargs="?", choices=["api", *_service_names()], default="api"
|
|
34
|
+
)
|
|
35
|
+
return parser
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
39
|
+
parser = _parser()
|
|
40
|
+
args = parser.parse_args(argv)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
if args.command == "services":
|
|
44
|
+
print("\n".join(_service_names()))
|
|
45
|
+
elif args.command == "install":
|
|
46
|
+
path = _service(args.service).install(force=args.force)
|
|
47
|
+
print(f"installed {args.service}: {path}")
|
|
48
|
+
elif args.command == "start" and args.service != "api":
|
|
49
|
+
_service(args.service).start()
|
|
50
|
+
elif args.command == "start":
|
|
51
|
+
uvicorn.run(
|
|
52
|
+
"funmill.api:app",
|
|
53
|
+
host=os.getenv("FUNMILL_HOST", "127.0.0.1"),
|
|
54
|
+
port=int(os.getenv("FUNMILL_PORT", "8000")),
|
|
55
|
+
)
|
|
56
|
+
else:
|
|
57
|
+
parser.print_help()
|
|
58
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
59
|
+
parser.exit(1, f"error: {exc}\n")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
main()
|
funmill/models.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from enum import StrEnum
|
|
3
|
+
from typing import Annotated, Any, Self
|
|
4
|
+
|
|
5
|
+
from pydantic import AnyHttpUrl, BaseModel, Field, model_validator
|
|
6
|
+
|
|
7
|
+
DependencyId = Annotated[str, Field(min_length=1, max_length=200)]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TaskLanguage(StrEnum):
|
|
11
|
+
PYTHON = "python"
|
|
12
|
+
BASH = "bash"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TaskStatus(StrEnum):
|
|
16
|
+
QUEUED = "queued"
|
|
17
|
+
RUNNING = "running"
|
|
18
|
+
SUCCEEDED = "succeeded"
|
|
19
|
+
FAILED = "failed"
|
|
20
|
+
CANCELED = "canceled"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RetryPolicy(BaseModel):
|
|
24
|
+
attempts: int = Field(default=0, ge=0, le=10)
|
|
25
|
+
delay_seconds: int = Field(default=1, ge=0, le=3600)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TaskDefinition(BaseModel):
|
|
29
|
+
language: TaskLanguage
|
|
30
|
+
source: str = Field(min_length=1, max_length=1_000_000)
|
|
31
|
+
args: dict[str, Any] = Field(default_factory=dict)
|
|
32
|
+
retry: RetryPolicy = Field(default_factory=RetryPolicy)
|
|
33
|
+
timeout_seconds: int | None = Field(default=None, ge=1, le=86_400)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TaskSubmit(TaskDefinition):
|
|
37
|
+
depends_on: list[DependencyId] = Field(default_factory=list, max_length=100)
|
|
38
|
+
dependency_timeout_seconds: int = Field(default=86_400, ge=1, le=604_800)
|
|
39
|
+
callback_url: AnyHttpUrl | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class WorkflowTask(TaskDefinition):
|
|
43
|
+
key: str = Field(pattern=r"^[A-Za-z][A-Za-z0-9_-]{0,63}$")
|
|
44
|
+
depends_on: list[str] = Field(default_factory=list, max_length=100)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class WorkflowSubmit(BaseModel):
|
|
48
|
+
tasks: list[WorkflowTask] = Field(min_length=1, max_length=100)
|
|
49
|
+
depends_on: list[DependencyId] = Field(default_factory=list, max_length=100)
|
|
50
|
+
dependency_timeout_seconds: int = Field(default=86_400, ge=1, le=604_800)
|
|
51
|
+
callback_url: AnyHttpUrl | None = None
|
|
52
|
+
|
|
53
|
+
@model_validator(mode="after")
|
|
54
|
+
def validate_dependencies(self) -> Self:
|
|
55
|
+
keys = [task.key for task in self.tasks]
|
|
56
|
+
if len(keys) != len(set(keys)):
|
|
57
|
+
raise ValueError("workflow task keys must be unique")
|
|
58
|
+
if any(key == "failure" or key.startswith("funmill_") for key in keys):
|
|
59
|
+
raise ValueError("task keys cannot be 'failure' or start with 'funmill_'")
|
|
60
|
+
|
|
61
|
+
known = set(keys)
|
|
62
|
+
for task in self.tasks:
|
|
63
|
+
unknown = set(task.depends_on) - known
|
|
64
|
+
if unknown:
|
|
65
|
+
raise ValueError(
|
|
66
|
+
f"task {task.key!r} has unknown dependencies: {sorted(unknown)}"
|
|
67
|
+
)
|
|
68
|
+
self.topological_layers()
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def topological_layers(self) -> list[list[WorkflowTask]]:
|
|
72
|
+
by_key = {task.key: task for task in self.tasks}
|
|
73
|
+
remaining = {task.key: set(task.depends_on) for task in self.tasks}
|
|
74
|
+
layers: list[list[WorkflowTask]] = []
|
|
75
|
+
while remaining:
|
|
76
|
+
ready = [key for key, dependencies in remaining.items() if not dependencies]
|
|
77
|
+
if not ready:
|
|
78
|
+
raise ValueError("workflow contains a dependency cycle")
|
|
79
|
+
layers.append([by_key[key] for key in ready])
|
|
80
|
+
ready_set = set(ready)
|
|
81
|
+
remaining = {
|
|
82
|
+
key: dependencies - ready_set
|
|
83
|
+
for key, dependencies in remaining.items()
|
|
84
|
+
if key not in ready_set
|
|
85
|
+
}
|
|
86
|
+
return layers
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TaskAccepted(BaseModel):
|
|
90
|
+
task_id: str
|
|
91
|
+
status: TaskStatus = TaskStatus.QUEUED
|
|
92
|
+
rerun_of: str | None = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TaskInfo(BaseModel):
|
|
96
|
+
task_id: str
|
|
97
|
+
status: TaskStatus
|
|
98
|
+
created_at: datetime | None = None
|
|
99
|
+
started_at: datetime | None = None
|
|
100
|
+
completed_at: datetime | None = None
|
|
101
|
+
duration_ms: int | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TaskProgress(BaseModel):
|
|
105
|
+
task_id: str
|
|
106
|
+
progress: int | None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class TaskLogs(BaseModel):
|
|
110
|
+
task_id: str
|
|
111
|
+
logs: str
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TaskResult(BaseModel):
|
|
115
|
+
task_id: str
|
|
116
|
+
result: Any
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class CancelRequest(BaseModel):
|
|
120
|
+
reason: str = Field(default="canceled through Funmill", max_length=500)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: funmill
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Backend-neutral task execution API
|
|
5
|
+
Project-URL: Organization, https://github.com/farfarfun
|
|
6
|
+
Project-URL: Repository, https://github.com/farfarfun/funmill
|
|
7
|
+
Project-URL: Releases, https://github.com/farfarfun/funmill/releases
|
|
8
|
+
Author-email: 牛哥 <niuliangtao@qq.com>, farfarfun <farfarfun@qq.com>
|
|
9
|
+
Maintainer-email: 牛哥 <niuliangtao@qq.com>, farfarfun <farfarfun@qq.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Requires-Dist: fastapi<1,>=0.115
|
|
13
|
+
Requires-Dist: httpx<1,>=0.27
|
|
14
|
+
Requires-Dist: uvicorn<1,>=0.30
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Funmill
|
|
18
|
+
|
|
19
|
+
Funmill provides one stable task API while execution is delegated to a
|
|
20
|
+
replaceable backend. The first backend is self-hosted
|
|
21
|
+
[Windmill](https://github.com/windmill-labs/windmill), pinned to `v1.808.0`.
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
client -> Funmill /v1 -> TaskBackend -> Windmill
|
|
25
|
+
-> another backend later
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Funmill owns the public request and response models. Backend job IDs remain
|
|
29
|
+
opaque strings, and no Windmill routes or payloads are exposed to clients.
|
|
30
|
+
|
|
31
|
+
## Start
|
|
32
|
+
|
|
33
|
+
Install the project and the Windmill binary:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uv sync
|
|
37
|
+
uv run funmill install windmill
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Start Windmill against an existing PostgreSQL database:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
DATABASE_URL='postgres://windmill:password@127.0.0.1:5432/windmill' \
|
|
44
|
+
uv run funmill start windmill
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Open <http://localhost:8001>, log in with `admin@windmill.dev` / `changeme`,
|
|
48
|
+
change the password, and create an API token in the `admins` workspace. Then
|
|
49
|
+
start Funmill in another terminal:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
FUNMILL_API_KEY='replace-me' \
|
|
53
|
+
WINDMILL_URL='http://127.0.0.1:8001' \
|
|
54
|
+
WINDMILL_WORKSPACE=admins \
|
|
55
|
+
WINDMILL_TOKEN='replace-me' \
|
|
56
|
+
uv run funmill start
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The API is at <http://localhost:8000>; OpenAPI docs are at
|
|
60
|
+
<http://localhost:8000/docs>. All `/v1` routes require `X-API-Key`. See the
|
|
61
|
+
[Windmill deployment guide](src/funmill/backends/windmill/README.md) for
|
|
62
|
+
PostgreSQL setup and additional workers.
|
|
63
|
+
|
|
64
|
+
Run the end-to-end task and DAG checks with:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
FUNMILL_API_KEY=the-value-from-env ./scripts/smoke.sh
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Set `FUNMILL_CALLBACK_URL` to test callbacks. The URL must be reachable from
|
|
71
|
+
the Windmill workers.
|
|
72
|
+
|
|
73
|
+
## API
|
|
74
|
+
|
|
75
|
+
| Operation | Route |
|
|
76
|
+
| --- | --- |
|
|
77
|
+
| Submit Python/Bash | `POST /v1/tasks` |
|
|
78
|
+
| Submit a DAG | `POST /v1/workflows` |
|
|
79
|
+
| Status | `GET /v1/tasks/{task_id}` |
|
|
80
|
+
| Logs | `GET /v1/tasks/{task_id}/logs` |
|
|
81
|
+
| Progress | `GET /v1/tasks/{task_id}/progress` |
|
|
82
|
+
| Result | `GET /v1/tasks/{task_id}/result` |
|
|
83
|
+
| Cancel | `POST /v1/tasks/{task_id}/cancel` |
|
|
84
|
+
| Rerun | `POST /v1/tasks/{task_id}/rerun` |
|
|
85
|
+
|
|
86
|
+
Submit one task, optionally waiting for existing task IDs:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"language": "python",
|
|
91
|
+
"source": "def main(value: int):\n return value * 2\n",
|
|
92
|
+
"args": {"value": 21},
|
|
93
|
+
"depends_on": ["EXISTING_TASK_ID"],
|
|
94
|
+
"dependency_timeout_seconds": 3600,
|
|
95
|
+
"retry": {"attempts": 2, "delay_seconds": 5},
|
|
96
|
+
"timeout_seconds": 300,
|
|
97
|
+
"callback_url": "https://example.internal/task-callback"
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Submit `A -> [B, C]` as one workflow:
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"tasks": [
|
|
106
|
+
{"key": "a", "language": "python", "source": "def main(): return 1"},
|
|
107
|
+
{"key": "b", "language": "python", "source": "def main(): return 2", "depends_on": ["a"]},
|
|
108
|
+
{"key": "c", "language": "bash", "source": "main() { echo 3; }", "depends_on": ["a"]}
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Dependencies inside a workflow are task keys. Top-level `depends_on` values are
|
|
114
|
+
IDs returned by earlier Funmill submissions. The Windmill backend checks those
|
|
115
|
+
dependencies in short jobs separated by Flow sleeps, so waiting does not hold a
|
|
116
|
+
worker process. Failure or cancellation of a dependency fails the waiting task.
|
|
117
|
+
Workflow results and callback payloads are objects keyed by every workflow task
|
|
118
|
+
key. Each topological layer is a synchronization barrier; tasks in the same
|
|
119
|
+
layer run in parallel.
|
|
120
|
+
|
|
121
|
+
Callbacks contain `task_id`, `status`, and `payload`; success and failure
|
|
122
|
+
delivery retry three times. Delivery is at least once, so callback receivers
|
|
123
|
+
must be idempotent.
|
|
124
|
+
|
|
125
|
+
## Backends
|
|
126
|
+
|
|
127
|
+
The public contract is `TaskBackend` in `src/funmill/backends/base.py`. Backend
|
|
128
|
+
selection uses `FUNMILL_BACKEND`; registration lives in
|
|
129
|
+
`src/funmill/backends/__init__.py`, following the same driver pattern as
|
|
130
|
+
`fundrive`. Each third-party adapter lives in its own directory, such as
|
|
131
|
+
`src/funmill/backends/windmill/`.
|
|
132
|
+
|
|
133
|
+
Changing the backend does not change `/v1`, but it does not migrate old jobs or
|
|
134
|
+
their IDs. Add a Funmill-owned ID mapping database only when jobs must remain
|
|
135
|
+
queryable after a live backend migration.
|
|
136
|
+
|
|
137
|
+
## Operations
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
funmill services
|
|
141
|
+
funmill install windmill
|
|
142
|
+
funmill start windmill
|
|
143
|
+
funmill start
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Both start commands run in the foreground and stop normally with `Ctrl+C`.
|
|
147
|
+
Use systemd or an existing process manager for long-running deployment. Add
|
|
148
|
+
TLS, PostgreSQL backups, callback egress restrictions, and a secrets manager
|
|
149
|
+
before network exposure.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
funmill/__init__.py,sha256=8nPJr4q1cpkaKWSURBmqUYnzF1ZFtnP1e3dZUT6OloA,687
|
|
2
|
+
funmill/api.py,sha256=y9B6Q6SH0P1ysBaIkwlUjVvFZBfo2t8vvBA7Ajz80Oo,3094
|
|
3
|
+
funmill/cli.py,sha256=nhjz6IOA9yJqViP-eoW6zukJaBsBz4uwV8DUwt8IWHU,1980
|
|
4
|
+
funmill/models.py,sha256=c0R-gJi2Tfo8TZKDz-Gwmd5Qd4fGmrNE_n5AjV2jmzM,3756
|
|
5
|
+
funmill/backends/__init__.py,sha256=0bpElxJzkCbD13WqujfR_ojG-hGVLMaxvauJ2WvXy5Y,1000
|
|
6
|
+
funmill/backends/base.py,sha256=2_dv5BXEzq8h5WV0lEtk7ccbjzvsrEDnFA4tcJuajEA,1066
|
|
7
|
+
funmill/backends/windmill/README.md,sha256=IXAHqgYwbb_Al7i_Y7mmimtVkTr76MNavIudQg5LofQ,2334
|
|
8
|
+
funmill/backends/windmill/__init__.py,sha256=-0K_z4FRTOxl-gNlBpU3zUIhbzyqMVUcuqUh9SNwZsI,13758
|
|
9
|
+
funmill/backends/windmill/service.py,sha256=hYPC3ZMKLKyqliPVEACUMRLqmeybuij5-luviLVfbpw,2647
|
|
10
|
+
funmill-0.1.1.dist-info/METADATA,sha256=XEkJuyrw4nyyHvxXLGkfQOPTvenPKLQwm8mpqNodHRg,4881
|
|
11
|
+
funmill-0.1.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
12
|
+
funmill-0.1.1.dist-info/entry_points.txt,sha256=EIJo5giXw8ZTlQcpPjbzEiG9A1qaLOd4eiBXHE6fYPE,45
|
|
13
|
+
funmill-0.1.1.dist-info/RECORD,,
|