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,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 二进制。